@litemetrics/node 0.1.0 → 0.1.2

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
@@ -153,6 +153,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
153
153
  title Nullable(String),
154
154
  event_name Nullable(String),
155
155
  properties Nullable(String),
156
+ event_source LowCardinality(Nullable(String)),
157
+ event_subtype LowCardinality(Nullable(String)),
158
+ page_path Nullable(String),
159
+ target_url_path Nullable(String),
160
+ element_selector Nullable(String),
161
+ element_text Nullable(String),
162
+ scroll_depth_pct Nullable(UInt8),
156
163
  user_id Nullable(String),
157
164
  traits Nullable(String),
158
165
  country LowCardinality(Nullable(String)),
@@ -184,6 +191,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
184
191
  name String,
185
192
  domain Nullable(String),
186
193
  allowed_origins Nullable(String),
194
+ conversion_events Nullable(String),
187
195
  created_at DateTime64(3),
188
196
  updated_at DateTime64(3),
189
197
  version UInt64,
@@ -192,6 +200,43 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
192
200
  ORDER BY (site_id)
193
201
  SETTINGS index_granularity = 8192
194
202
  `;
203
+ function toCHDateTime(d) {
204
+ const iso = typeof d === "string" ? d : d.toISOString();
205
+ return iso.replace("T", " ").replace("Z", "");
206
+ }
207
+ function buildFilterConditions(filters) {
208
+ if (!filters) return { conditions: [], params: {} };
209
+ const map = {
210
+ "geo.country": "country",
211
+ "geo.city": "city",
212
+ "geo.region": "region",
213
+ "language": "language",
214
+ "device.type": "device_type",
215
+ "device.browser": "browser",
216
+ "device.os": "os",
217
+ "utm.source": "utm_source",
218
+ "utm.medium": "utm_medium",
219
+ "utm.campaign": "utm_campaign",
220
+ "utm.term": "utm_term",
221
+ "utm.content": "utm_content",
222
+ "referrer": "referrer",
223
+ "event_source": "event_source",
224
+ "event_subtype": "event_subtype",
225
+ "page_path": "page_path",
226
+ "target_url_path": "target_url_path",
227
+ "event_name": "event_name",
228
+ "type": "type"
229
+ };
230
+ const conditions = [];
231
+ const params = {};
232
+ for (const [key, value] of Object.entries(filters)) {
233
+ if (!value || !map[key]) continue;
234
+ const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
235
+ conditions.push(`${map[key]} = {${paramKey}:String}`);
236
+ params[paramKey] = value;
237
+ }
238
+ return { conditions, params };
239
+ }
195
240
  var ClickHouseAdapter = class {
196
241
  client;
197
242
  constructor(url) {
@@ -205,6 +250,16 @@ var ClickHouseAdapter = class {
205
250
  async init() {
206
251
  await this.client.command({ query: CREATE_EVENTS_TABLE });
207
252
  await this.client.command({ query: CREATE_SITES_TABLE });
253
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_source LowCardinality(Nullable(String))` });
254
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS event_subtype LowCardinality(Nullable(String))` });
255
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS page_path Nullable(String)` });
256
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS target_url_path Nullable(String)` });
257
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS element_selector Nullable(String)` });
258
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS element_text Nullable(String)` });
259
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS scroll_depth_pct Nullable(UInt8)` });
260
+ await this.client.command({
261
+ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
262
+ });
208
263
  }
209
264
  async close() {
210
265
  await this.client.close();
@@ -215,7 +270,7 @@ var ClickHouseAdapter = class {
215
270
  const rows = events.map((e) => ({
216
271
  site_id: e.siteId,
217
272
  type: e.type,
218
- timestamp: new Date(e.timestamp).toISOString(),
273
+ timestamp: toCHDateTime(new Date(e.timestamp)),
219
274
  session_id: e.sessionId,
220
275
  visitor_id: e.visitorId,
221
276
  url: e.url ?? null,
@@ -223,6 +278,13 @@ var ClickHouseAdapter = class {
223
278
  title: e.title ?? null,
224
279
  event_name: e.name ?? null,
225
280
  properties: e.properties ? JSON.stringify(e.properties) : null,
281
+ event_source: e.eventSource ?? null,
282
+ event_subtype: e.eventSubtype ?? null,
283
+ page_path: e.pagePath ?? null,
284
+ target_url_path: e.targetUrlPath ?? null,
285
+ element_selector: e.elementSelector ?? null,
286
+ element_text: e.elementText ?? null,
287
+ scroll_depth_pct: e.scrollDepthPct ?? null,
226
288
  user_id: e.userId ?? null,
227
289
  traits: e.traits ? JSON.stringify(e.traits) : null,
228
290
  country: e.geo?.country ?? null,
@@ -255,10 +317,12 @@ var ClickHouseAdapter = class {
255
317
  const limit = q.limit ?? 10;
256
318
  const params = {
257
319
  siteId,
258
- from: dateRange.from,
259
- to: dateRange.to,
320
+ from: toCHDateTime(dateRange.from),
321
+ to: toCHDateTime(dateRange.to),
260
322
  limit
261
323
  };
324
+ const filter = buildFilterConditions(q.filters);
325
+ const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
262
326
  let data = [];
263
327
  let total = 0;
264
328
  switch (q.metric) {
@@ -268,8 +332,8 @@ var ClickHouseAdapter = class {
268
332
  WHERE site_id = {siteId:String}
269
333
  AND timestamp >= {from:String}
270
334
  AND timestamp <= {to:String}
271
- AND type = 'pageview'`,
272
- params
335
+ AND type = 'pageview'${filterSql}`,
336
+ { ...params, ...filter.params }
273
337
  );
274
338
  total = Number(rows[0]?.value ?? 0);
275
339
  data = [{ key: "pageviews", value: total }];
@@ -280,8 +344,8 @@ var ClickHouseAdapter = class {
280
344
  `SELECT uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
281
345
  WHERE site_id = {siteId:String}
282
346
  AND timestamp >= {from:String}
283
- AND timestamp <= {to:String}`,
284
- params
347
+ AND timestamp <= {to:String}${filterSql}`,
348
+ { ...params, ...filter.params }
285
349
  );
286
350
  total = Number(rows[0]?.value ?? 0);
287
351
  data = [{ key: "visitors", value: total }];
@@ -292,8 +356,8 @@ var ClickHouseAdapter = class {
292
356
  `SELECT uniq(session_id) AS value FROM ${EVENTS_TABLE}
293
357
  WHERE site_id = {siteId:String}
294
358
  AND timestamp >= {from:String}
295
- AND timestamp <= {to:String}`,
296
- params
359
+ AND timestamp <= {to:String}${filterSql}`,
360
+ { ...params, ...filter.params }
297
361
  );
298
362
  total = Number(rows[0]?.value ?? 0);
299
363
  data = [{ key: "sessions", value: total }];
@@ -305,13 +369,33 @@ var ClickHouseAdapter = class {
305
369
  WHERE site_id = {siteId:String}
306
370
  AND timestamp >= {from:String}
307
371
  AND timestamp <= {to:String}
308
- AND type = 'event'`,
309
- params
372
+ AND type = 'event'${filterSql}`,
373
+ { ...params, ...filter.params }
310
374
  );
311
375
  total = Number(rows[0]?.value ?? 0);
312
376
  data = [{ key: "events", value: total }];
313
377
  break;
314
378
  }
379
+ case "conversions": {
380
+ const conversionEvents = q.conversionEvents ?? [];
381
+ if (conversionEvents.length === 0) {
382
+ total = 0;
383
+ data = [{ key: "conversions", value: 0 }];
384
+ break;
385
+ }
386
+ const rows = await this.queryRows(
387
+ `SELECT count() AS value FROM ${EVENTS_TABLE}
388
+ WHERE site_id = {siteId:String}
389
+ AND timestamp >= {from:String}
390
+ AND timestamp <= {to:String}
391
+ AND type = 'event'
392
+ AND event_name IN {eventNames:Array(String)}${filterSql}`,
393
+ { ...params, eventNames: conversionEvents, ...filter.params }
394
+ );
395
+ total = Number(rows[0]?.value ?? 0);
396
+ data = [{ key: "conversions", value: total }];
397
+ break;
398
+ }
315
399
  case "top_pages": {
316
400
  const rows = await this.queryRows(
317
401
  `SELECT url AS key, count() AS value FROM ${EVENTS_TABLE}
@@ -319,11 +403,11 @@ var ClickHouseAdapter = class {
319
403
  AND timestamp >= {from:String}
320
404
  AND timestamp <= {to:String}
321
405
  AND type = 'pageview'
322
- AND url IS NOT NULL
406
+ AND url IS NOT NULL${filterSql}
323
407
  GROUP BY url
324
408
  ORDER BY value DESC
325
409
  LIMIT {limit:UInt32}`,
326
- params
410
+ { ...params, ...filter.params }
327
411
  );
328
412
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
329
413
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -337,11 +421,11 @@ var ClickHouseAdapter = class {
337
421
  AND timestamp <= {to:String}
338
422
  AND type = 'pageview'
339
423
  AND referrer IS NOT NULL
340
- AND referrer != ''
424
+ AND referrer != ''${filterSql}
341
425
  GROUP BY referrer
342
426
  ORDER BY value DESC
343
427
  LIMIT {limit:UInt32}`,
344
- params
428
+ { ...params, ...filter.params }
345
429
  );
346
430
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
347
431
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -353,11 +437,11 @@ var ClickHouseAdapter = class {
353
437
  WHERE site_id = {siteId:String}
354
438
  AND timestamp >= {from:String}
355
439
  AND timestamp <= {to:String}
356
- AND country IS NOT NULL
440
+ AND country IS NOT NULL${filterSql}
357
441
  GROUP BY country
358
442
  ORDER BY value DESC
359
443
  LIMIT {limit:UInt32}`,
360
- params
444
+ { ...params, ...filter.params }
361
445
  );
362
446
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
363
447
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -369,11 +453,11 @@ var ClickHouseAdapter = class {
369
453
  WHERE site_id = {siteId:String}
370
454
  AND timestamp >= {from:String}
371
455
  AND timestamp <= {to:String}
372
- AND city IS NOT NULL
456
+ AND city IS NOT NULL${filterSql}
373
457
  GROUP BY city
374
458
  ORDER BY value DESC
375
459
  LIMIT {limit:UInt32}`,
376
- params
460
+ { ...params, ...filter.params }
377
461
  );
378
462
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
379
463
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -387,10 +471,132 @@ var ClickHouseAdapter = class {
387
471
  AND timestamp <= {to:String}
388
472
  AND type = 'event'
389
473
  AND event_name IS NOT NULL
474
+ ${filterSql}
475
+ GROUP BY event_name
476
+ ORDER BY value DESC
477
+ LIMIT {limit:UInt32}`,
478
+ { ...params, ...filter.params }
479
+ );
480
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
481
+ total = data.reduce((sum, d) => sum + d.value, 0);
482
+ break;
483
+ }
484
+ case "top_conversions": {
485
+ const conversionEvents = q.conversionEvents ?? [];
486
+ if (conversionEvents.length === 0) {
487
+ total = 0;
488
+ data = [];
489
+ break;
490
+ }
491
+ const rows = await this.queryRows(
492
+ `SELECT event_name AS key, count() AS value FROM ${EVENTS_TABLE}
493
+ WHERE site_id = {siteId:String}
494
+ AND timestamp >= {from:String}
495
+ AND timestamp <= {to:String}
496
+ AND type = 'event'
497
+ AND event_name IN {eventNames:Array(String)}
498
+ ${filterSql}
390
499
  GROUP BY event_name
391
500
  ORDER BY value DESC
392
501
  LIMIT {limit:UInt32}`,
393
- params
502
+ { ...params, eventNames: conversionEvents, ...filter.params }
503
+ );
504
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
505
+ total = data.reduce((sum, d) => sum + d.value, 0);
506
+ break;
507
+ }
508
+ case "top_exit_pages": {
509
+ const rows = await this.queryRows(
510
+ `SELECT url AS key, count() AS value FROM (
511
+ SELECT session_id, argMax(url, timestamp) AS url
512
+ FROM ${EVENTS_TABLE}
513
+ WHERE site_id = {siteId:String}
514
+ AND timestamp >= {from:String}
515
+ AND timestamp <= {to:String}
516
+ AND type = 'pageview'
517
+ AND url IS NOT NULL${filterSql}
518
+ GROUP BY session_id
519
+ )
520
+ GROUP BY url
521
+ ORDER BY value DESC
522
+ LIMIT {limit:UInt32}`,
523
+ { ...params, ...filter.params }
524
+ );
525
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
526
+ total = data.reduce((sum, d) => sum + d.value, 0);
527
+ break;
528
+ }
529
+ case "top_transitions": {
530
+ const rows = await this.queryRows(
531
+ `SELECT concat(prev_url, ' \u2192 ', url) AS key, count() AS value FROM (
532
+ SELECT session_id, url,
533
+ lag(url) OVER (PARTITION BY session_id ORDER BY timestamp) AS prev_url
534
+ FROM ${EVENTS_TABLE}
535
+ WHERE site_id = {siteId:String}
536
+ AND timestamp >= {from:String}
537
+ AND timestamp <= {to:String}
538
+ AND type = 'pageview'
539
+ AND url IS NOT NULL${filterSql}
540
+ )
541
+ WHERE prev_url IS NOT NULL
542
+ GROUP BY key
543
+ ORDER BY value DESC
544
+ LIMIT {limit:UInt32}`,
545
+ { ...params, ...filter.params }
546
+ );
547
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
548
+ total = data.reduce((sum, d) => sum + d.value, 0);
549
+ break;
550
+ }
551
+ case "top_scroll_pages": {
552
+ const rows = await this.queryRows(
553
+ `SELECT page_path AS key, count() AS value FROM ${EVENTS_TABLE}
554
+ WHERE site_id = {siteId:String}
555
+ AND timestamp >= {from:String}
556
+ AND timestamp <= {to:String}
557
+ AND type = 'event'
558
+ AND event_subtype = 'scroll_depth'
559
+ AND page_path IS NOT NULL${filterSql}
560
+ GROUP BY page_path
561
+ ORDER BY value DESC
562
+ LIMIT {limit:UInt32}`,
563
+ { ...params, ...filter.params }
564
+ );
565
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
566
+ total = data.reduce((sum, d) => sum + d.value, 0);
567
+ break;
568
+ }
569
+ case "top_button_clicks": {
570
+ const rows = await this.queryRows(
571
+ `SELECT ifNull(element_text, element_selector) AS key, count() AS value FROM ${EVENTS_TABLE}
572
+ WHERE site_id = {siteId:String}
573
+ AND timestamp >= {from:String}
574
+ AND timestamp <= {to:String}
575
+ AND type = 'event'
576
+ AND event_subtype = 'button_click'
577
+ AND (element_text IS NOT NULL OR element_selector IS NOT NULL)${filterSql}
578
+ GROUP BY key
579
+ ORDER BY value DESC
580
+ LIMIT {limit:UInt32}`,
581
+ { ...params, ...filter.params }
582
+ );
583
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
584
+ total = data.reduce((sum, d) => sum + d.value, 0);
585
+ break;
586
+ }
587
+ case "top_link_targets": {
588
+ const rows = await this.queryRows(
589
+ `SELECT target_url_path AS key, count() AS value FROM ${EVENTS_TABLE}
590
+ WHERE site_id = {siteId:String}
591
+ AND timestamp >= {from:String}
592
+ AND timestamp <= {to:String}
593
+ AND type = 'event'
594
+ AND event_subtype IN ('link_click','outbound_click')
595
+ AND target_url_path IS NOT NULL${filterSql}
596
+ GROUP BY target_url_path
597
+ ORDER BY value DESC
598
+ LIMIT {limit:UInt32}`,
599
+ { ...params, ...filter.params }
394
600
  );
395
601
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
396
602
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -403,10 +609,11 @@ var ClickHouseAdapter = class {
403
609
  AND timestamp >= {from:String}
404
610
  AND timestamp <= {to:String}
405
611
  AND device_type IS NOT NULL
612
+ ${filterSql}
406
613
  GROUP BY device_type
407
614
  ORDER BY value DESC
408
615
  LIMIT {limit:UInt32}`,
409
- params
616
+ { ...params, ...filter.params }
410
617
  );
411
618
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
412
619
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -419,10 +626,11 @@ var ClickHouseAdapter = class {
419
626
  AND timestamp >= {from:String}
420
627
  AND timestamp <= {to:String}
421
628
  AND browser IS NOT NULL
629
+ ${filterSql}
422
630
  GROUP BY browser
423
631
  ORDER BY value DESC
424
632
  LIMIT {limit:UInt32}`,
425
- params
633
+ { ...params, ...filter.params }
426
634
  );
427
635
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
428
636
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -435,10 +643,11 @@ var ClickHouseAdapter = class {
435
643
  AND timestamp >= {from:String}
436
644
  AND timestamp <= {to:String}
437
645
  AND os IS NOT NULL
646
+ ${filterSql}
438
647
  GROUP BY os
439
648
  ORDER BY value DESC
440
649
  LIMIT {limit:UInt32}`,
441
- params
650
+ { ...params, ...filter.params }
442
651
  );
443
652
  data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
444
653
  total = data.reduce((sum, d) => sum + d.value, 0);
@@ -446,7 +655,7 @@ var ClickHouseAdapter = class {
446
655
  }
447
656
  }
448
657
  const result = { metric: q.metric, period, data, total };
449
- if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
658
+ if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
450
659
  const prevRange = previousPeriodRange(dateRange);
451
660
  const prevResult = await this.query({
452
661
  ...q,
@@ -476,7 +685,12 @@ var ClickHouseAdapter = class {
476
685
  const granularity = params.granularity ?? autoGranularity(period);
477
686
  const bucketFn = this.granularityToClickHouseFunc(granularity);
478
687
  const dateFormat = granularityToDateFormat(granularity);
688
+ const filter = buildFilterConditions(params.filters);
689
+ const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
479
690
  const typeFilter = params.metric === "pageviews" ? `AND type = 'pageview'` : "";
691
+ const eventsFilter = params.metric === "events" ? `AND type = 'event'` : "";
692
+ const conversionsFilter = params.metric === "conversions" ? `AND type = 'event' AND event_name IN {eventNames:Array(String)}` : "";
693
+ const extraFilters = [typeFilter, eventsFilter, conversionsFilter, filterSql].filter(Boolean).join(" ");
480
694
  let sql;
481
695
  if (params.metric === "visitors" || params.metric === "sessions") {
482
696
  const field = params.metric === "visitors" ? "visitor_id" : "session_id";
@@ -486,7 +700,7 @@ var ClickHouseAdapter = class {
486
700
  WHERE site_id = {siteId:String}
487
701
  AND timestamp >= {from:String}
488
702
  AND timestamp <= {to:String}
489
- ${typeFilter}
703
+ ${extraFilters}
490
704
  GROUP BY bucket
491
705
  ORDER BY bucket ASC
492
706
  `;
@@ -497,15 +711,17 @@ var ClickHouseAdapter = class {
497
711
  WHERE site_id = {siteId:String}
498
712
  AND timestamp >= {from:String}
499
713
  AND timestamp <= {to:String}
500
- ${typeFilter}
714
+ ${extraFilters}
501
715
  GROUP BY bucket
502
716
  ORDER BY bucket ASC
503
717
  `;
504
718
  }
505
719
  const rows = await this.queryRows(sql, {
506
720
  siteId: params.siteId,
507
- from: dateRange.from,
508
- to: dateRange.to
721
+ from: toCHDateTime(dateRange.from),
722
+ to: toCHDateTime(dateRange.to),
723
+ eventNames: params.conversionEvents ?? [],
724
+ ...filter.params
509
725
  });
510
726
  const mappedRows = rows.map((r) => ({
511
727
  _id: this.convertClickHouseBucket(r.bucket, granularity),
@@ -571,7 +787,7 @@ var ClickHouseAdapter = class {
571
787
  GROUP BY visitor_id`,
572
788
  {
573
789
  siteId: params.siteId,
574
- since: startDate.toISOString()
790
+ since: toCHDateTime(startDate)
575
791
  }
576
792
  );
577
793
  const cohortMap = /* @__PURE__ */ new Map();
@@ -623,6 +839,14 @@ var ClickHouseAdapter = class {
623
839
  conditions.push(`event_name = {eventName:String}`);
624
840
  queryParams.eventName = params.eventName;
625
841
  }
842
+ if (params.eventSource) {
843
+ conditions.push(`event_source = {eventSource:String}`);
844
+ queryParams.eventSource = params.eventSource;
845
+ }
846
+ if (params.eventNames && params.eventNames.length > 0) {
847
+ conditions.push(`event_name IN {eventNames:Array(String)}`);
848
+ queryParams.eventNames = params.eventNames;
849
+ }
626
850
  if (params.visitorId) {
627
851
  conditions.push(`visitor_id = {visitorId:String}`);
628
852
  queryParams.visitorId = params.visitorId;
@@ -638,14 +862,16 @@ var ClickHouseAdapter = class {
638
862
  dateTo: params.dateTo
639
863
  });
640
864
  conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
641
- queryParams.from = dateRange.from;
642
- queryParams.to = dateRange.to;
865
+ queryParams.from = toCHDateTime(dateRange.from);
866
+ queryParams.to = toCHDateTime(dateRange.to);
643
867
  }
644
868
  const where = conditions.join(" AND ");
645
869
  const [events, countRows] = await Promise.all([
646
870
  this.queryRows(
647
871
  `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
648
- event_name, properties, user_id, traits, country, city, region,
872
+ event_name, properties, event_source, event_subtype, page_path, target_url_path,
873
+ element_selector, element_text, scroll_depth_pct,
874
+ user_id, traits, country, city, region,
649
875
  device_type, browser, os, language,
650
876
  utm_source, utm_medium, utm_campaign, utm_term, utm_content
651
877
  FROM ${EVENTS_TABLE}
@@ -690,13 +916,22 @@ var ClickHouseAdapter = class {
690
916
  countIf(type = 'pageview') AS totalPageviews,
691
917
  uniq(session_id) AS totalSessions,
692
918
  anyLast(url) AS lastUrl,
919
+ anyLast(referrer) AS referrer,
693
920
  anyLast(device_type) AS device_type,
694
921
  anyLast(browser) AS browser,
695
922
  anyLast(os) AS os,
696
923
  anyLast(country) AS country,
697
924
  anyLast(city) AS city,
698
925
  anyLast(region) AS region,
699
- anyLast(language) AS language
926
+ anyLast(language) AS language,
927
+ anyLast(timezone) AS timezone,
928
+ anyLast(screen_width) AS screen_width,
929
+ anyLast(screen_height) AS screen_height,
930
+ anyLast(utm_source) AS utm_source,
931
+ anyLast(utm_medium) AS utm_medium,
932
+ anyLast(utm_campaign) AS utm_campaign,
933
+ anyLast(utm_term) AS utm_term,
934
+ anyLast(utm_content) AS utm_content
700
935
  FROM ${EVENTS_TABLE}
701
936
  WHERE ${where}
702
937
  GROUP BY visitor_id
@@ -724,9 +959,19 @@ var ClickHouseAdapter = class {
724
959
  totalPageviews: Number(u.totalPageviews),
725
960
  totalSessions: Number(u.totalSessions),
726
961
  lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
962
+ referrer: u.referrer ? String(u.referrer) : void 0,
727
963
  device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
728
964
  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,
729
- language: u.language ? String(u.language) : void 0
965
+ language: u.language ? String(u.language) : void 0,
966
+ timezone: u.timezone ? String(u.timezone) : void 0,
967
+ screen: u.screen_width || u.screen_height ? { width: Number(u.screen_width ?? 0), height: Number(u.screen_height ?? 0) } : void 0,
968
+ utm: u.utm_source ? {
969
+ source: String(u.utm_source),
970
+ medium: u.utm_medium ? String(u.utm_medium) : void 0,
971
+ campaign: u.utm_campaign ? String(u.utm_campaign) : void 0,
972
+ term: u.utm_term ? String(u.utm_term) : void 0,
973
+ content: u.utm_content ? String(u.utm_content) : void 0
974
+ } : void 0
730
975
  }));
731
976
  return {
732
977
  users,
@@ -745,15 +990,18 @@ var ClickHouseAdapter = class {
745
990
  }
746
991
  // ─── Site Management ──────────────────────────────────────
747
992
  async createSite(data) {
748
- const now = (/* @__PURE__ */ new Date()).toISOString();
993
+ const now = /* @__PURE__ */ new Date();
994
+ const nowISO = now.toISOString();
995
+ const nowCH = toCHDateTime(now);
749
996
  const site = {
750
997
  siteId: generateSiteId(),
751
998
  secretKey: generateSecretKey(),
752
999
  name: data.name,
753
1000
  domain: data.domain,
754
1001
  allowedOrigins: data.allowedOrigins,
755
- createdAt: now,
756
- updatedAt: now
1002
+ conversionEvents: data.conversionEvents,
1003
+ createdAt: nowISO,
1004
+ updatedAt: nowISO
757
1005
  };
758
1006
  await this.client.insert({
759
1007
  table: SITES_TABLE,
@@ -763,8 +1011,9 @@ var ClickHouseAdapter = class {
763
1011
  name: site.name,
764
1012
  domain: site.domain ?? null,
765
1013
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
766
- created_at: now,
767
- updated_at: now,
1014
+ conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
1015
+ created_at: nowCH,
1016
+ updated_at: nowCH,
768
1017
  version: 1,
769
1018
  is_deleted: 0
770
1019
  }],
@@ -774,7 +1023,7 @@ var ClickHouseAdapter = class {
774
1023
  }
775
1024
  async getSite(siteId) {
776
1025
  const rows = await this.queryRows(
777
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1026
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
778
1027
  FROM ${SITES_TABLE} FINAL
779
1028
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
780
1029
  { siteId }
@@ -783,7 +1032,7 @@ var ClickHouseAdapter = class {
783
1032
  }
784
1033
  async getSiteBySecret(secretKey) {
785
1034
  const rows = await this.queryRows(
786
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1035
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
787
1036
  FROM ${SITES_TABLE} FINAL
788
1037
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
789
1038
  { secretKey }
@@ -792,7 +1041,7 @@ var ClickHouseAdapter = class {
792
1041
  }
793
1042
  async listSites() {
794
1043
  const rows = await this.queryRows(
795
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
1044
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
796
1045
  FROM ${SITES_TABLE} FINAL
797
1046
  WHERE is_deleted = 0
798
1047
  ORDER BY created_at DESC`,
@@ -802,18 +1051,21 @@ var ClickHouseAdapter = class {
802
1051
  }
803
1052
  async updateSite(siteId, data) {
804
1053
  const currentRows = await this.queryRows(
805
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at, version
1054
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
806
1055
  FROM ${SITES_TABLE} FINAL
807
1056
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
808
1057
  { siteId }
809
1058
  );
810
1059
  if (currentRows.length === 0) return null;
811
1060
  const current = currentRows[0];
812
- const now = (/* @__PURE__ */ new Date()).toISOString();
1061
+ const now = /* @__PURE__ */ new Date();
1062
+ const nowISO = now.toISOString();
1063
+ const nowCH = toCHDateTime(now);
813
1064
  const newVersion = Number(current.version) + 1;
814
1065
  const newName = data.name !== void 0 ? data.name : String(current.name);
815
1066
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
816
1067
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1068
+ const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
817
1069
  await this.client.insert({
818
1070
  table: SITES_TABLE,
819
1071
  values: [{
@@ -822,8 +1074,9 @@ var ClickHouseAdapter = class {
822
1074
  name: newName,
823
1075
  domain: newDomain,
824
1076
  allowed_origins: newOrigins,
825
- created_at: String(current.created_at),
826
- updated_at: now,
1077
+ conversion_events: newConversions,
1078
+ created_at: toCHDateTime(String(current.created_at)),
1079
+ updated_at: nowCH,
827
1080
  version: newVersion,
828
1081
  is_deleted: 0
829
1082
  }],
@@ -835,20 +1088,21 @@ var ClickHouseAdapter = class {
835
1088
  name: newName,
836
1089
  domain: newDomain ?? void 0,
837
1090
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1091
+ conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
838
1092
  createdAt: String(current.created_at),
839
- updatedAt: now
1093
+ updatedAt: nowISO
840
1094
  };
841
1095
  }
842
1096
  async deleteSite(siteId) {
843
1097
  const currentRows = await this.queryRows(
844
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
1098
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
845
1099
  FROM ${SITES_TABLE} FINAL
846
1100
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
847
1101
  { siteId }
848
1102
  );
849
1103
  if (currentRows.length === 0) return false;
850
1104
  const current = currentRows[0];
851
- const now = (/* @__PURE__ */ new Date()).toISOString();
1105
+ const nowCH = toCHDateTime(/* @__PURE__ */ new Date());
852
1106
  await this.client.insert({
853
1107
  table: SITES_TABLE,
854
1108
  values: [{
@@ -857,8 +1111,9 @@ var ClickHouseAdapter = class {
857
1111
  name: String(current.name),
858
1112
  domain: current.domain ? String(current.domain) : null,
859
1113
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
860
- created_at: String(current.created_at),
861
- updated_at: now,
1114
+ conversion_events: current.conversion_events ? String(current.conversion_events) : null,
1115
+ created_at: toCHDateTime(String(current.created_at)),
1116
+ updated_at: nowCH,
862
1117
  version: Number(current.version) + 1,
863
1118
  is_deleted: 1
864
1119
  }],
@@ -868,14 +1123,16 @@ var ClickHouseAdapter = class {
868
1123
  }
869
1124
  async regenerateSecret(siteId) {
870
1125
  const currentRows = await this.queryRows(
871
- `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
1126
+ `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
872
1127
  FROM ${SITES_TABLE} FINAL
873
1128
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
874
1129
  { siteId }
875
1130
  );
876
1131
  if (currentRows.length === 0) return null;
877
1132
  const current = currentRows[0];
878
- const now = (/* @__PURE__ */ new Date()).toISOString();
1133
+ const now = /* @__PURE__ */ new Date();
1134
+ const nowISO = now.toISOString();
1135
+ const nowCH = toCHDateTime(now);
879
1136
  const newSecret = generateSecretKey();
880
1137
  await this.client.insert({
881
1138
  table: SITES_TABLE,
@@ -885,8 +1142,9 @@ var ClickHouseAdapter = class {
885
1142
  name: String(current.name),
886
1143
  domain: current.domain ? String(current.domain) : null,
887
1144
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
888
- created_at: String(current.created_at),
889
- updated_at: now,
1145
+ conversion_events: current.conversion_events ? String(current.conversion_events) : null,
1146
+ created_at: toCHDateTime(String(current.created_at)),
1147
+ updated_at: nowCH,
890
1148
  version: Number(current.version) + 1,
891
1149
  is_deleted: 0
892
1150
  }],
@@ -898,8 +1156,9 @@ var ClickHouseAdapter = class {
898
1156
  name: String(current.name),
899
1157
  domain: current.domain ? String(current.domain) : void 0,
900
1158
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1159
+ conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
901
1160
  createdAt: String(current.created_at),
902
- updatedAt: now
1161
+ updatedAt: nowISO
903
1162
  };
904
1163
  }
905
1164
  // ─── Helpers ─────────────────────────────────────────────
@@ -918,6 +1177,7 @@ var ClickHouseAdapter = class {
918
1177
  name: String(row.name),
919
1178
  domain: row.domain ? String(row.domain) : void 0,
920
1179
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1180
+ conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
921
1181
  createdAt: new Date(String(row.created_at)).toISOString(),
922
1182
  updatedAt: new Date(String(row.updated_at)).toISOString()
923
1183
  };
@@ -934,6 +1194,13 @@ var ClickHouseAdapter = class {
934
1194
  title: row.title ? String(row.title) : void 0,
935
1195
  name: row.event_name ? String(row.event_name) : void 0,
936
1196
  properties: this.parseJSON(row.properties),
1197
+ eventSource: row.event_source ? String(row.event_source) : void 0,
1198
+ eventSubtype: row.event_subtype ? String(row.event_subtype) : void 0,
1199
+ pagePath: row.page_path ? String(row.page_path) : void 0,
1200
+ targetUrlPath: row.target_url_path ? String(row.target_url_path) : void 0,
1201
+ elementSelector: row.element_selector ? String(row.element_selector) : void 0,
1202
+ elementText: row.element_text ? String(row.element_text) : void 0,
1203
+ scrollDepthPct: row.scroll_depth_pct !== null && row.scroll_depth_pct !== void 0 ? Number(row.scroll_depth_pct) : void 0,
937
1204
  userId: row.user_id ? String(row.user_id) : void 0,
938
1205
  traits: this.parseJSON(row.traits),
939
1206
  geo: row.country ? {
@@ -970,6 +1237,36 @@ var ClickHouseAdapter = class {
970
1237
  import { MongoClient } from "mongodb";
971
1238
  var EVENTS_COLLECTION = "litemetrics_events";
972
1239
  var SITES_COLLECTION = "litemetrics_sites";
1240
+ function buildFilterMatch(filters) {
1241
+ if (!filters) return {};
1242
+ const map = {
1243
+ "geo.country": "country",
1244
+ "geo.city": "city",
1245
+ "geo.region": "region",
1246
+ "language": "language",
1247
+ "device.type": "device_type",
1248
+ "device.browser": "browser",
1249
+ "device.os": "os",
1250
+ "utm.source": "utm_source",
1251
+ "utm.medium": "utm_medium",
1252
+ "utm.campaign": "utm_campaign",
1253
+ "utm.term": "utm_term",
1254
+ "utm.content": "utm_content",
1255
+ "referrer": "referrer",
1256
+ "event_source": "event_source",
1257
+ "event_subtype": "event_subtype",
1258
+ "page_path": "page_path",
1259
+ "target_url_path": "target_url_path",
1260
+ "event_name": "event_name",
1261
+ "type": "type"
1262
+ };
1263
+ const match = {};
1264
+ for (const [key, value] of Object.entries(filters)) {
1265
+ if (!value || !map[key]) continue;
1266
+ match[map[key]] = value;
1267
+ }
1268
+ return match;
1269
+ }
973
1270
  var MongoDBAdapter = class {
974
1271
  client;
975
1272
  db;
@@ -1005,6 +1302,13 @@ var MongoDBAdapter = class {
1005
1302
  title: e.title ?? null,
1006
1303
  event_name: e.name ?? null,
1007
1304
  properties: e.properties ?? null,
1305
+ event_source: e.eventSource ?? null,
1306
+ event_subtype: e.eventSubtype ?? null,
1307
+ page_path: e.pagePath ?? null,
1308
+ target_url_path: e.targetUrlPath ?? null,
1309
+ element_selector: e.elementSelector ?? null,
1310
+ element_text: e.elementText ?? null,
1311
+ scroll_depth_pct: e.scrollDepthPct ?? null,
1008
1312
  user_id: e.userId ?? null,
1009
1313
  traits: e.traits ?? null,
1010
1314
  country: e.geo?.country ?? null,
@@ -1035,12 +1339,13 @@ var MongoDBAdapter = class {
1035
1339
  site_id: siteId,
1036
1340
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1037
1341
  };
1342
+ const filterMatch = buildFilterMatch(q.filters);
1038
1343
  let data = [];
1039
1344
  let total = 0;
1040
1345
  switch (q.metric) {
1041
1346
  case "pageviews": {
1042
1347
  const [result2] = await this.collection.aggregate([
1043
- { $match: { ...baseMatch, type: "pageview" } },
1348
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
1044
1349
  { $count: "count" }
1045
1350
  ]).toArray();
1046
1351
  total = result2?.count ?? 0;
@@ -1049,7 +1354,7 @@ var MongoDBAdapter = class {
1049
1354
  }
1050
1355
  case "visitors": {
1051
1356
  const [result2] = await this.collection.aggregate([
1052
- { $match: baseMatch },
1357
+ { $match: { ...baseMatch, ...filterMatch } },
1053
1358
  { $group: { _id: "$visitor_id" } },
1054
1359
  { $count: "count" }
1055
1360
  ]).toArray();
@@ -1059,7 +1364,7 @@ var MongoDBAdapter = class {
1059
1364
  }
1060
1365
  case "sessions": {
1061
1366
  const [result2] = await this.collection.aggregate([
1062
- { $match: baseMatch },
1367
+ { $match: { ...baseMatch, ...filterMatch } },
1063
1368
  { $group: { _id: "$session_id" } },
1064
1369
  { $count: "count" }
1065
1370
  ]).toArray();
@@ -1069,16 +1374,31 @@ var MongoDBAdapter = class {
1069
1374
  }
1070
1375
  case "events": {
1071
1376
  const [result2] = await this.collection.aggregate([
1072
- { $match: { ...baseMatch, type: "event" } },
1377
+ { $match: { ...baseMatch, ...filterMatch, type: "event" } },
1073
1378
  { $count: "count" }
1074
1379
  ]).toArray();
1075
1380
  total = result2?.count ?? 0;
1076
1381
  data = [{ key: "events", value: total }];
1077
1382
  break;
1078
1383
  }
1384
+ case "conversions": {
1385
+ const conversionEvents = q.conversionEvents ?? [];
1386
+ if (conversionEvents.length === 0) {
1387
+ total = 0;
1388
+ data = [{ key: "conversions", value: 0 }];
1389
+ break;
1390
+ }
1391
+ const [result2] = await this.collection.aggregate([
1392
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1393
+ { $count: "count" }
1394
+ ]).toArray();
1395
+ total = result2?.count ?? 0;
1396
+ data = [{ key: "conversions", value: total }];
1397
+ break;
1398
+ }
1079
1399
  case "top_pages": {
1080
1400
  const rows = await this.collection.aggregate([
1081
- { $match: { ...baseMatch, type: "pageview", url: { $ne: null } } },
1401
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1082
1402
  { $group: { _id: "$url", value: { $sum: 1 } } },
1083
1403
  { $sort: { value: -1 } },
1084
1404
  { $limit: limit }
@@ -1089,7 +1409,7 @@ var MongoDBAdapter = class {
1089
1409
  }
1090
1410
  case "top_referrers": {
1091
1411
  const rows = await this.collection.aggregate([
1092
- { $match: { ...baseMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1412
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1093
1413
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1094
1414
  { $sort: { value: -1 } },
1095
1415
  { $limit: limit }
@@ -1100,7 +1420,7 @@ var MongoDBAdapter = class {
1100
1420
  }
1101
1421
  case "top_countries": {
1102
1422
  const rows = await this.collection.aggregate([
1103
- { $match: { ...baseMatch, country: { $ne: null } } },
1423
+ { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
1104
1424
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1105
1425
  { $project: { _id: 1, value: { $size: "$value" } } },
1106
1426
  { $sort: { value: -1 } },
@@ -1112,7 +1432,7 @@ var MongoDBAdapter = class {
1112
1432
  }
1113
1433
  case "top_cities": {
1114
1434
  const rows = await this.collection.aggregate([
1115
- { $match: { ...baseMatch, city: { $ne: null } } },
1435
+ { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
1116
1436
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1117
1437
  { $project: { _id: 1, value: { $size: "$value" } } },
1118
1438
  { $sort: { value: -1 } },
@@ -1124,7 +1444,7 @@ var MongoDBAdapter = class {
1124
1444
  }
1125
1445
  case "top_events": {
1126
1446
  const rows = await this.collection.aggregate([
1127
- { $match: { ...baseMatch, type: "event", event_name: { $ne: null } } },
1447
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
1128
1448
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1129
1449
  { $sort: { value: -1 } },
1130
1450
  { $limit: limit }
@@ -1133,9 +1453,109 @@ var MongoDBAdapter = class {
1133
1453
  total = data.reduce((sum, d) => sum + d.value, 0);
1134
1454
  break;
1135
1455
  }
1456
+ case "top_conversions": {
1457
+ const conversionEvents = q.conversionEvents ?? [];
1458
+ if (conversionEvents.length === 0) {
1459
+ total = 0;
1460
+ data = [];
1461
+ break;
1462
+ }
1463
+ const rows = await this.collection.aggregate([
1464
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
1465
+ { $group: { _id: "$event_name", value: { $sum: 1 } } },
1466
+ { $sort: { value: -1 } },
1467
+ { $limit: limit }
1468
+ ]).toArray();
1469
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1470
+ total = data.reduce((sum, d) => sum + d.value, 0);
1471
+ break;
1472
+ }
1473
+ case "top_exit_pages": {
1474
+ const rows = await this.collection.aggregate([
1475
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1476
+ { $sort: { timestamp: 1 } },
1477
+ { $group: { _id: "$session_id", url: { $last: "$url" } } },
1478
+ { $group: { _id: "$url", value: { $sum: 1 } } },
1479
+ { $sort: { value: -1 } },
1480
+ { $limit: limit }
1481
+ ]).toArray();
1482
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1483
+ total = data.reduce((sum, d) => sum + d.value, 0);
1484
+ break;
1485
+ }
1486
+ case "top_transitions": {
1487
+ const rows = await this.collection.aggregate([
1488
+ { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
1489
+ {
1490
+ $setWindowFields: {
1491
+ partitionBy: "$session_id",
1492
+ sortBy: { timestamp: 1 },
1493
+ output: {
1494
+ prev_url: { $shift: { output: "$url", by: -1 } }
1495
+ }
1496
+ }
1497
+ },
1498
+ { $match: { prev_url: { $ne: null } } },
1499
+ { $group: { _id: { $concat: ["$prev_url", " \u2192 ", "$url"] }, value: { $sum: 1 } } },
1500
+ { $sort: { value: -1 } },
1501
+ { $limit: limit }
1502
+ ]).toArray();
1503
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1504
+ total = data.reduce((sum, d) => sum + d.value, 0);
1505
+ break;
1506
+ }
1507
+ case "top_scroll_pages": {
1508
+ const rows = await this.collection.aggregate([
1509
+ { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
1510
+ { $group: { _id: "$page_path", value: { $sum: 1 } } },
1511
+ { $sort: { value: -1 } },
1512
+ { $limit: limit }
1513
+ ]).toArray();
1514
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1515
+ total = data.reduce((sum, d) => sum + d.value, 0);
1516
+ break;
1517
+ }
1518
+ case "top_button_clicks": {
1519
+ const rows = await this.collection.aggregate([
1520
+ {
1521
+ $match: {
1522
+ ...baseMatch,
1523
+ ...filterMatch,
1524
+ type: "event",
1525
+ event_subtype: "button_click",
1526
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1527
+ }
1528
+ },
1529
+ { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1530
+ { $sort: { value: -1 } },
1531
+ { $limit: limit }
1532
+ ]).toArray();
1533
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1534
+ total = data.reduce((sum, d) => sum + d.value, 0);
1535
+ break;
1536
+ }
1537
+ case "top_link_targets": {
1538
+ const rows = await this.collection.aggregate([
1539
+ {
1540
+ $match: {
1541
+ ...baseMatch,
1542
+ ...filterMatch,
1543
+ type: "event",
1544
+ event_subtype: { $in: ["link_click", "outbound_click"] },
1545
+ target_url_path: { $ne: null }
1546
+ }
1547
+ },
1548
+ { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1549
+ { $sort: { value: -1 } },
1550
+ { $limit: limit }
1551
+ ]).toArray();
1552
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1553
+ total = data.reduce((sum, d) => sum + d.value, 0);
1554
+ break;
1555
+ }
1136
1556
  case "top_devices": {
1137
1557
  const rows = await this.collection.aggregate([
1138
- { $match: { ...baseMatch, device_type: { $ne: null } } },
1558
+ { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
1139
1559
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1140
1560
  { $project: { _id: 1, value: { $size: "$value" } } },
1141
1561
  { $sort: { value: -1 } },
@@ -1147,7 +1567,7 @@ var MongoDBAdapter = class {
1147
1567
  }
1148
1568
  case "top_browsers": {
1149
1569
  const rows = await this.collection.aggregate([
1150
- { $match: { ...baseMatch, browser: { $ne: null } } },
1570
+ { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
1151
1571
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1152
1572
  { $project: { _id: 1, value: { $size: "$value" } } },
1153
1573
  { $sort: { value: -1 } },
@@ -1159,7 +1579,7 @@ var MongoDBAdapter = class {
1159
1579
  }
1160
1580
  case "top_os": {
1161
1581
  const rows = await this.collection.aggregate([
1162
- { $match: { ...baseMatch, os: { $ne: null } } },
1582
+ { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
1163
1583
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1164
1584
  { $project: { _id: 1, value: { $size: "$value" } } },
1165
1585
  { $sort: { value: -1 } },
@@ -1171,7 +1591,7 @@ var MongoDBAdapter = class {
1171
1591
  }
1172
1592
  }
1173
1593
  const result = { metric: q.metric, period, data, total };
1174
- if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
1594
+ if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
1175
1595
  const prevRange = previousPeriodRange(dateRange);
1176
1596
  const prevResult = await this.query({
1177
1597
  ...q,
@@ -1203,15 +1623,34 @@ var MongoDBAdapter = class {
1203
1623
  site_id: params.siteId,
1204
1624
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1205
1625
  };
1626
+ const filterMatch = buildFilterMatch(params.filters);
1206
1627
  if (params.metric === "pageviews") {
1207
1628
  baseMatch.type = "pageview";
1208
1629
  }
1630
+ if (params.metric === "events") {
1631
+ baseMatch.type = "event";
1632
+ }
1633
+ if (params.metric === "conversions") {
1634
+ baseMatch.type = "event";
1635
+ const conversionEvents = params.conversionEvents ?? [];
1636
+ if (conversionEvents.length === 0) {
1637
+ const data2 = fillBuckets(
1638
+ new Date(dateRange.from),
1639
+ new Date(dateRange.to),
1640
+ granularity,
1641
+ granularityToDateFormat(granularity),
1642
+ []
1643
+ );
1644
+ return { metric: params.metric, granularity, data: data2 };
1645
+ }
1646
+ baseMatch.event_name = { $in: conversionEvents };
1647
+ }
1209
1648
  const dateFormat = granularityToDateFormat(granularity);
1210
1649
  let pipeline;
1211
1650
  if (params.metric === "visitors" || params.metric === "sessions") {
1212
1651
  const groupField = params.metric === "visitors" ? "$visitor_id" : "$session_id";
1213
1652
  pipeline = [
1214
- { $match: baseMatch },
1653
+ { $match: { ...baseMatch, ...filterMatch } },
1215
1654
  {
1216
1655
  $group: {
1217
1656
  _id: {
@@ -1230,7 +1669,7 @@ var MongoDBAdapter = class {
1230
1669
  ];
1231
1670
  } else {
1232
1671
  pipeline = [
1233
- { $match: baseMatch },
1672
+ { $match: { ...baseMatch, ...filterMatch } },
1234
1673
  {
1235
1674
  $group: {
1236
1675
  _id: { $dateToString: { format: dateFormat, date: "$timestamp" } },
@@ -1311,7 +1750,12 @@ var MongoDBAdapter = class {
1311
1750
  const offset = params.offset ?? 0;
1312
1751
  const match = { site_id: params.siteId };
1313
1752
  if (params.type) match.type = params.type;
1314
- if (params.eventName) match.event_name = params.eventName;
1753
+ if (params.eventName) {
1754
+ match.event_name = params.eventName;
1755
+ } else if (params.eventNames && params.eventNames.length > 0) {
1756
+ match.event_name = { $in: params.eventNames };
1757
+ }
1758
+ if (params.eventSource) match.event_source = params.eventSource;
1315
1759
  if (params.visitorId) match.visitor_id = params.visitorId;
1316
1760
  if (params.userId) match.user_id = params.userId;
1317
1761
  if (params.period || params.dateFrom) {
@@ -1346,6 +1790,7 @@ var MongoDBAdapter = class {
1346
1790
  }
1347
1791
  const pipeline = [
1348
1792
  { $match: match },
1793
+ { $sort: { timestamp: 1 } },
1349
1794
  {
1350
1795
  $group: {
1351
1796
  _id: "$visitor_id",
@@ -1357,13 +1802,22 @@ var MongoDBAdapter = class {
1357
1802
  totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
1358
1803
  sessions: { $addToSet: "$session_id" },
1359
1804
  lastUrl: { $last: "$url" },
1805
+ referrer: { $last: "$referrer" },
1360
1806
  device_type: { $last: "$device_type" },
1361
1807
  browser: { $last: "$browser" },
1362
1808
  os: { $last: "$os" },
1363
1809
  country: { $last: "$country" },
1364
1810
  city: { $last: "$city" },
1365
1811
  region: { $last: "$region" },
1366
- language: { $last: "$language" }
1812
+ language: { $last: "$language" },
1813
+ timezone: { $last: "$timezone" },
1814
+ screen_width: { $last: "$screen_width" },
1815
+ screen_height: { $last: "$screen_height" },
1816
+ utm_source: { $last: "$utm_source" },
1817
+ utm_medium: { $last: "$utm_medium" },
1818
+ utm_campaign: { $last: "$utm_campaign" },
1819
+ utm_term: { $last: "$utm_term" },
1820
+ utm_content: { $last: "$utm_content" }
1367
1821
  }
1368
1822
  },
1369
1823
  { $sort: { lastSeen: -1 } },
@@ -1385,9 +1839,19 @@ var MongoDBAdapter = class {
1385
1839
  totalPageviews: u.totalPageviews,
1386
1840
  totalSessions: u.sessions.length,
1387
1841
  lastUrl: u.lastUrl ?? void 0,
1842
+ referrer: u.referrer ?? void 0,
1388
1843
  device: u.device_type ? { type: u.device_type, browser: u.browser ?? "", os: u.os ?? "" } : void 0,
1389
1844
  geo: u.country ? { country: u.country, city: u.city ?? void 0, region: u.region ?? void 0 } : void 0,
1390
- language: u.language ?? void 0
1845
+ language: u.language ?? void 0,
1846
+ timezone: u.timezone ?? void 0,
1847
+ screen: u.screen_width || u.screen_height ? { width: u.screen_width ?? 0, height: u.screen_height ?? 0 } : void 0,
1848
+ utm: u.utm_source ? {
1849
+ source: u.utm_source ?? void 0,
1850
+ medium: u.utm_medium ?? void 0,
1851
+ campaign: u.utm_campaign ?? void 0,
1852
+ term: u.utm_term ?? void 0,
1853
+ content: u.utm_content ?? void 0
1854
+ } : void 0
1391
1855
  }));
1392
1856
  return {
1393
1857
  users,
@@ -1416,6 +1880,13 @@ var MongoDBAdapter = class {
1416
1880
  title: doc.title ?? void 0,
1417
1881
  name: doc.event_name ?? void 0,
1418
1882
  properties: doc.properties ?? void 0,
1883
+ eventSource: doc.event_source ? doc.event_source : void 0,
1884
+ eventSubtype: doc.event_subtype ? doc.event_subtype : void 0,
1885
+ pagePath: doc.page_path ?? void 0,
1886
+ targetUrlPath: doc.target_url_path ?? void 0,
1887
+ elementSelector: doc.element_selector ?? void 0,
1888
+ elementText: doc.element_text ?? void 0,
1889
+ scrollDepthPct: doc.scroll_depth_pct ?? void 0,
1419
1890
  userId: doc.user_id ?? void 0,
1420
1891
  traits: doc.traits ?? void 0,
1421
1892
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
@@ -1439,6 +1910,7 @@ var MongoDBAdapter = class {
1439
1910
  name: data.name,
1440
1911
  domain: data.domain ?? null,
1441
1912
  allowed_origins: data.allowedOrigins ?? null,
1913
+ conversion_events: data.conversionEvents ?? null,
1442
1914
  created_at: now,
1443
1915
  updated_at: now
1444
1916
  };
@@ -1462,6 +1934,7 @@ var MongoDBAdapter = class {
1462
1934
  if (data.name !== void 0) updates.name = data.name;
1463
1935
  if (data.domain !== void 0) updates.domain = data.domain || null;
1464
1936
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
1937
+ if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
1465
1938
  const result = await this.sites.findOneAndUpdate(
1466
1939
  { site_id: siteId },
1467
1940
  { $set: updates },
@@ -1492,6 +1965,7 @@ var MongoDBAdapter = class {
1492
1965
  name: doc.name,
1493
1966
  domain: doc.domain ?? void 0,
1494
1967
  allowedOrigins: doc.allowed_origins ?? void 0,
1968
+ conversionEvents: doc.conversion_events ?? void 0,
1495
1969
  createdAt: doc.created_at.toISOString(),
1496
1970
  updatedAt: doc.updated_at.toISOString()
1497
1971
  };
@@ -1662,6 +2136,63 @@ function resolveDeviceType(type) {
1662
2136
  return "desktop";
1663
2137
  }
1664
2138
 
2139
+ // src/botfilter.ts
2140
+ var BOT_PATTERNS = [
2141
+ // Headless browsers
2142
+ /HeadlessChrome/i,
2143
+ /PhantomJS/i,
2144
+ /Selenium/i,
2145
+ /Puppeteer/i,
2146
+ /Playwright/i,
2147
+ // Common bots
2148
+ /bot\b/i,
2149
+ /spider/i,
2150
+ /crawl/i,
2151
+ /slurp/i,
2152
+ /mediapartners/i,
2153
+ /facebookexternalhit/i,
2154
+ /Twitterbot/i,
2155
+ /LinkedInBot/i,
2156
+ /WhatsApp/i,
2157
+ /Discordbot/i,
2158
+ /TelegramBot/i,
2159
+ /Applebot/i,
2160
+ /Baiduspider/i,
2161
+ /YandexBot/i,
2162
+ /DuckDuckBot/i,
2163
+ /Sogou/i,
2164
+ /Exabot/i,
2165
+ /ia_archiver/i,
2166
+ // HTTP libraries & API tools
2167
+ /PostmanRuntime/i,
2168
+ /axios/i,
2169
+ /node-fetch/i,
2170
+ /python-requests/i,
2171
+ /Go-http-client/i,
2172
+ /Java\//i,
2173
+ /libwww-perl/i,
2174
+ /wget/i,
2175
+ /curl/i,
2176
+ /httpie/i,
2177
+ // Monitoring / uptime
2178
+ /UptimeRobot/i,
2179
+ /Pingdom/i,
2180
+ /StatusCake/i,
2181
+ /Site24x7/i,
2182
+ /NewRelic/i,
2183
+ /Datadog/i,
2184
+ // Preview/embed
2185
+ /Slackbot/i,
2186
+ /Embedly/i,
2187
+ /Quora Link Preview/i,
2188
+ /redditbot/i,
2189
+ /Pinterestbot/i
2190
+ ];
2191
+ function isBot(ua) {
2192
+ if (!ua || ua.length === 0) return true;
2193
+ return BOT_PATTERNS.some((re) => re.test(ua));
2194
+ }
2195
+
1665
2196
  // src/collector.ts
1666
2197
  async function createCollector(config) {
1667
2198
  const db = createAdapter(config.db);
@@ -1688,6 +2219,7 @@ async function createCollector(config) {
1688
2219
  if (allowed) {
1689
2220
  res.setHeader?.("Access-Control-Allow-Origin", origin || "*");
1690
2221
  res.setHeader?.("Access-Control-Allow-Methods", methods);
2222
+ res.setHeader?.("Access-Control-Allow-Credentials", "true");
1691
2223
  const headers = ["Content-Type", extraHeaders].filter(Boolean).join(", ");
1692
2224
  res.setHeader?.("Access-Control-Allow-Headers", headers);
1693
2225
  }
@@ -1718,7 +2250,14 @@ async function createCollector(config) {
1718
2250
  }
1719
2251
  function handler() {
1720
2252
  return async (req, res) => {
1721
- if (setCors(req, res, "POST, OPTIONS")) return;
2253
+ res.setHeader?.("Access-Control-Allow-Origin", "*");
2254
+ res.setHeader?.("Access-Control-Allow-Methods", "POST, OPTIONS");
2255
+ res.setHeader?.("Access-Control-Allow-Headers", "Content-Type");
2256
+ if (req.method === "OPTIONS") {
2257
+ res.writeHead?.(204);
2258
+ res.end?.();
2259
+ return;
2260
+ }
1722
2261
  if (req.method !== "POST") {
1723
2262
  sendJson(res, 405, { ok: false, error: "Method not allowed" });
1724
2263
  return;
@@ -1734,9 +2273,36 @@ async function createCollector(config) {
1734
2273
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
1735
2274
  return;
1736
2275
  }
1737
- const ip = extractIp(req);
1738
2276
  const userAgent = req.headers?.["user-agent"] || "";
2277
+ if (isBot(userAgent)) {
2278
+ sendJson(res, 200, { ok: true });
2279
+ return;
2280
+ }
2281
+ const ip = extractIp(req);
1739
2282
  const enriched = enrichEvents(payload.events, ip, userAgent);
2283
+ const siteId = enriched[0]?.siteId;
2284
+ if (siteId) {
2285
+ const site = await db.getSite(siteId);
2286
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2287
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2288
+ const filtered = enriched.filter((event) => {
2289
+ if (!event.url) return true;
2290
+ try {
2291
+ const hostname = new URL(event.url).hostname.toLowerCase();
2292
+ return allowed.has(hostname);
2293
+ } catch {
2294
+ return true;
2295
+ }
2296
+ });
2297
+ if (filtered.length === 0) {
2298
+ sendJson(res, 200, { ok: true });
2299
+ return;
2300
+ }
2301
+ await db.insertEvents(filtered);
2302
+ sendJson(res, 200, { ok: true });
2303
+ return;
2304
+ }
2305
+ }
1740
2306
  await db.insertEvents(enriched);
1741
2307
  sendJson(res, 200, { ok: true });
1742
2308
  } catch (err) {
@@ -1770,8 +2336,13 @@ async function createCollector(config) {
1770
2336
  period: params.period,
1771
2337
  dateFrom: params.dateFrom,
1772
2338
  dateTo: params.dateTo,
1773
- granularity: q.granularity
2339
+ granularity: q.granularity,
2340
+ filters: q.filters ? JSON.parse(q.filters) : void 0
1774
2341
  };
2342
+ if (tsParams.metric === "conversions") {
2343
+ const site = await db.getSite(params.siteId);
2344
+ tsParams.conversionEvents = site?.conversionEvents ?? [];
2345
+ }
1775
2346
  const result2 = await db.queryTimeSeries(tsParams);
1776
2347
  sendJson(res, 200, result2);
1777
2348
  return;
@@ -1787,7 +2358,15 @@ async function createCollector(config) {
1787
2358
  sendJson(res, 200, result2);
1788
2359
  return;
1789
2360
  }
1790
- const result = await db.query(params);
2361
+ const isConversionMetric = params.metric === "conversions" || params.metric === "top_conversions";
2362
+ let result;
2363
+ if (isConversionMetric) {
2364
+ const site = await db.getSite(params.siteId);
2365
+ const conversionEvents = site?.conversionEvents ?? [];
2366
+ result = await db.query({ ...params, conversionEvents });
2367
+ } else {
2368
+ result = await db.query(params);
2369
+ }
1791
2370
  sendJson(res, 200, result);
1792
2371
  } catch (err) {
1793
2372
  sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
@@ -1884,10 +2463,13 @@ async function createCollector(config) {
1884
2463
  sendJson(res, 401, { ok: false, error: "Invalid or missing secret key" });
1885
2464
  return;
1886
2465
  }
2466
+ const eventNames = typeof q.eventNames === "string" ? q.eventNames.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
1887
2467
  const params = {
1888
2468
  siteId: q.siteId,
1889
2469
  type: q.type,
1890
2470
  eventName: q.eventName,
2471
+ eventNames,
2472
+ eventSource: q.eventSource,
1891
2473
  visitorId: q.visitorId,
1892
2474
  userId: q.userId,
1893
2475
  period: q.period,
@@ -1927,9 +2509,13 @@ async function createCollector(config) {
1927
2509
  const visitorId = usersIdx >= 0 ? pathSegments[usersIdx + 1] : void 0;
1928
2510
  const action = usersIdx >= 0 ? pathSegments[usersIdx + 2] : void 0;
1929
2511
  if (visitorId && action === "events") {
2512
+ const eventNames = typeof q.eventNames === "string" ? q.eventNames.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
1930
2513
  const params2 = {
1931
2514
  siteId: q.siteId,
1932
2515
  type: q.type,
2516
+ eventName: q.eventName,
2517
+ eventNames,
2518
+ eventSource: q.eventSource,
1933
2519
  period: q.period,
1934
2520
  dateFrom: q.dateFrom,
1935
2521
  dateTo: q.dateTo,
@@ -2036,7 +2622,8 @@ function createAdapter(config) {
2036
2622
  }
2037
2623
  }
2038
2624
  async function parseBody(req) {
2039
- if (req.body) return req.body;
2625
+ if (req.body && typeof req.body === "object") return req.body;
2626
+ if (typeof req.body === "string") return JSON.parse(req.body);
2040
2627
  return new Promise((resolve, reject) => {
2041
2628
  let data = "";
2042
2629
  req.on("data", (chunk) => {
@@ -2076,6 +2663,7 @@ function sendJson(res, status, body) {
2076
2663
  export {
2077
2664
  ClickHouseAdapter,
2078
2665
  MongoDBAdapter,
2079
- createCollector
2666
+ createCollector,
2667
+ isBot
2080
2668
  };
2081
2669
  //# sourceMappingURL=index.js.map