@litemetrics/node 0.1.3 → 0.3.0

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
@@ -3,6 +3,36 @@ import { createClient } from "@clickhouse/client";
3
3
 
4
4
  // src/adapters/utils.ts
5
5
  import { randomBytes } from "crypto";
6
+ function getTimezoneOffsetMs(date, timezone) {
7
+ const formatter = new Intl.DateTimeFormat("en-US", {
8
+ timeZone: timezone,
9
+ year: "numeric",
10
+ month: "2-digit",
11
+ day: "2-digit",
12
+ hour: "2-digit",
13
+ minute: "2-digit",
14
+ second: "2-digit",
15
+ hour12: false
16
+ });
17
+ const parts = formatter.formatToParts(date);
18
+ const get = (type) => parts.find((p) => p.type === type).value;
19
+ const y = parseInt(get("year"));
20
+ const m = parseInt(get("month")) - 1;
21
+ const d = parseInt(get("day"));
22
+ const h = parseInt(get("hour") === "24" ? "0" : get("hour"));
23
+ const mi = parseInt(get("minute"));
24
+ const s = parseInt(get("second"));
25
+ return Date.UTC(y, m, d, h, mi, s) - date.getTime();
26
+ }
27
+ function toUTCDate(value) {
28
+ if (value instanceof Date) return value;
29
+ if (typeof value === "number") return new Date(value);
30
+ const s = String(value).trim();
31
+ if (s.length >= 10 && !s.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(s)) {
32
+ return /* @__PURE__ */ new Date(s.replace(" ", "T") + "Z");
33
+ }
34
+ return new Date(s);
35
+ }
6
36
  function resolvePeriod(q) {
7
37
  const now = /* @__PURE__ */ new Date();
8
38
  const period = q.period ?? "7d";
@@ -69,60 +99,66 @@ function granularityToDateFormat(g) {
69
99
  return "%Y-%m";
70
100
  }
71
101
  }
72
- function fillBuckets(from, to, granularity, dateFormat, rows) {
102
+ function fillBuckets(from, to, granularity, dateFormat, rows, timezone) {
73
103
  const map = new Map(rows.map((r) => [r._id, r.value]));
74
104
  const points = [];
75
- const current = new Date(from);
105
+ const fromOffset = timezone ? getTimezoneOffsetMs(from, timezone) : 0;
106
+ const toOffset = timezone ? getTimezoneOffsetMs(to, timezone) : 0;
107
+ const current = new Date(from.getTime() + fromOffset);
108
+ const toWall = new Date(to.getTime() + toOffset);
76
109
  if (granularity === "hour") {
77
- current.setMinutes(0, 0, 0);
110
+ current.setUTCMinutes(0, 0, 0);
78
111
  } else if (granularity === "day") {
79
- current.setHours(0, 0, 0, 0);
112
+ current.setUTCHours(0, 0, 0, 0);
80
113
  } else if (granularity === "week") {
81
- const day = current.getDay();
114
+ const day = current.getUTCDay();
82
115
  const diff = day === 0 ? -6 : 1 - day;
83
- current.setDate(current.getDate() + diff);
84
- current.setHours(0, 0, 0, 0);
116
+ current.setUTCDate(current.getUTCDate() + diff);
117
+ current.setUTCHours(0, 0, 0, 0);
85
118
  } else if (granularity === "month") {
86
- current.setDate(1);
87
- current.setHours(0, 0, 0, 0);
119
+ current.setUTCDate(1);
120
+ current.setUTCHours(0, 0, 0, 0);
88
121
  }
89
- while (current <= to) {
122
+ while (current <= toWall) {
90
123
  const key = formatDateBucket(current, dateFormat);
91
- points.push({ date: current.toISOString(), value: map.get(key) ?? 0 });
124
+ const approxUtc = new Date(current.getTime() - fromOffset);
125
+ const exactOffset = timezone ? getTimezoneOffsetMs(approxUtc, timezone) : 0;
126
+ const realUtc = new Date(current.getTime() - exactOffset);
127
+ points.push({ date: realUtc.toISOString(), value: map.get(key) ?? 0 });
92
128
  if (granularity === "hour") {
93
- current.setHours(current.getHours() + 1);
129
+ current.setUTCHours(current.getUTCHours() + 1);
94
130
  } else if (granularity === "day") {
95
- current.setDate(current.getDate() + 1);
131
+ current.setUTCDate(current.getUTCDate() + 1);
96
132
  } else if (granularity === "week") {
97
- current.setDate(current.getDate() + 7);
133
+ current.setUTCDate(current.getUTCDate() + 7);
98
134
  } else if (granularity === "month") {
99
- current.setMonth(current.getMonth() + 1);
135
+ current.setUTCMonth(current.getUTCMonth() + 1);
100
136
  }
101
137
  }
102
138
  return points;
103
139
  }
104
140
  function formatDateBucket(date, format) {
105
- const y = date.getFullYear();
106
- const m = String(date.getMonth() + 1).padStart(2, "0");
107
- const d = String(date.getDate()).padStart(2, "0");
108
- const h = String(date.getHours()).padStart(2, "0");
141
+ const y = date.getUTCFullYear();
142
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
143
+ const d = String(date.getUTCDate()).padStart(2, "0");
144
+ const h = String(date.getUTCHours()).padStart(2, "0");
109
145
  if (format === "%Y-%m-%dT%H:00") return `${y}-${m}-${d}T${h}:00`;
110
146
  if (format === "%Y-%m-%d") return `${y}-${m}-${d}`;
111
147
  if (format === "%Y-%m") return `${y}-${m}`;
112
148
  if (format === "%G-W%V") {
113
- const jan4 = new Date(y, 0, 4);
114
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
115
- const jan4Day = jan4.getDay() || 7;
149
+ const jan4 = new Date(Date.UTC(y, 0, 4));
150
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
151
+ const jan4Day = jan4.getUTCDay() || 7;
116
152
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
117
153
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
118
154
  }
119
155
  return date.toISOString();
120
156
  }
121
157
  function getISOWeek(date) {
122
- const y = date.getFullYear();
123
- const jan4 = new Date(y, 0, 4);
124
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
125
- const jan4Day = jan4.getDay() || 7;
158
+ const y = date.getUTCFullYear();
159
+ const jan4 = new Date(Date.UTC(y, 0, 4));
160
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
161
+ const jan4Day = jan4.getUTCDay() || 7;
126
162
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
127
163
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
128
164
  }
@@ -137,6 +173,38 @@ function generateSecretKey() {
137
173
  return `sk_${randomBytes(32).toString("hex")}`;
138
174
  }
139
175
 
176
+ // src/query-helpers.ts
177
+ function isValidTimezone(tz) {
178
+ try {
179
+ Intl.DateTimeFormat(void 0, { timeZone: tz });
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+ function extractQueryParams(req) {
186
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
187
+ let timezone;
188
+ if (typeof q.timezone === "string" && q.timezone) {
189
+ if (isValidTimezone(q.timezone)) {
190
+ timezone = q.timezone;
191
+ } else {
192
+ console.warn(`[litemetrics] Invalid timezone "${q.timezone}", falling back to UTC`);
193
+ }
194
+ }
195
+ return {
196
+ siteId: q.siteId,
197
+ metric: q.metric,
198
+ period: q.period,
199
+ dateFrom: q.dateFrom,
200
+ dateTo: q.dateTo,
201
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
202
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
203
+ compare: q.compare === "true" || q.compare === "1",
204
+ timezone
205
+ };
206
+ }
207
+
140
208
  // src/adapters/clickhouse.ts
141
209
  var EVENTS_TABLE = "litemetrics_events";
142
210
  var SITES_TABLE = "litemetrics_sites";
@@ -190,6 +258,13 @@ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
190
258
  utm_term Nullable(String),
191
259
  utm_content Nullable(String),
192
260
  ip Nullable(String),
261
+ os_version LowCardinality(Nullable(String)),
262
+ device_model LowCardinality(Nullable(String)),
263
+ device_brand LowCardinality(Nullable(String)),
264
+ app_version LowCardinality(Nullable(String)),
265
+ app_build Nullable(String),
266
+ sdk_name LowCardinality(Nullable(String)),
267
+ sdk_version LowCardinality(Nullable(String)),
193
268
  created_at DateTime64(3) DEFAULT now64(3)
194
269
  ) ENGINE = MergeTree()
195
270
  PARTITION BY toYYYYMM(timestamp)
@@ -201,6 +276,7 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
201
276
  site_id String,
202
277
  secret_key String,
203
278
  name String,
279
+ type LowCardinality(Nullable(String)) DEFAULT 'web',
204
280
  domain Nullable(String),
205
281
  allowed_origins Nullable(String),
206
282
  conversion_events Nullable(String),
@@ -213,8 +289,74 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
213
289
  SETTINGS index_granularity = 8192
214
290
  `;
215
291
  function toCHDateTime(d) {
216
- const iso = typeof d === "string" ? d : d.toISOString();
217
- return iso.replace("T", " ").replace("Z", "");
292
+ const date = d instanceof Date ? d : toUTCDate(d);
293
+ const y = date.getUTCFullYear();
294
+ const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
295
+ const da = String(date.getUTCDate()).padStart(2, "0");
296
+ const h = String(date.getUTCHours()).padStart(2, "0");
297
+ const mi = String(date.getUTCMinutes()).padStart(2, "0");
298
+ const s = String(date.getUTCSeconds()).padStart(2, "0");
299
+ const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
300
+ return `${y}-${mo}-${da} ${h}:${mi}:${s}.${ms}`;
301
+ }
302
+ function normalizedUtmSourceExpr() {
303
+ return `multiIf(
304
+ lower(utm_source) IN ('ig','instagram','instagram.com'), 'Instagram',
305
+ lower(utm_source) IN ('fb','facebook','facebook.com','fb.com'), 'Facebook',
306
+ lower(utm_source) IN ('tw','twitter','twitter.com','x','x.com','t.co'), 'X (Twitter)',
307
+ lower(utm_source) IN ('li','linkedin','linkedin.com'), 'LinkedIn',
308
+ lower(utm_source) IN ('yt','youtube','youtube.com'), 'YouTube',
309
+ lower(utm_source) IN ('goog','google','google.com'), 'Google',
310
+ lower(utm_source) IN ('gh','github','github.com'), 'GitHub',
311
+ lower(utm_source) IN ('reddit','reddit.com'), 'Reddit',
312
+ lower(utm_source) IN ('pinterest','pinterest.com'), 'Pinterest',
313
+ lower(utm_source) IN ('tiktok','tiktok.com'), 'TikTok',
314
+ lower(utm_source) IN ('openai','chatgpt','chat.openai.com'), 'OpenAI',
315
+ lower(utm_source) IN ('perplexity','perplexity.ai'), 'Perplexity',
316
+ utm_source
317
+ )`;
318
+ }
319
+ function normalizedUtmMediumExpr() {
320
+ return `multiIf(
321
+ lower(utm_medium) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid'), 'Paid',
322
+ lower(utm_medium) IN ('organic'), 'Organic',
323
+ lower(utm_medium) IN ('social','social-media','social_media'), 'Social',
324
+ lower(utm_medium) IN ('email','e-mail','e_mail'), 'Email',
325
+ lower(utm_medium) IN ('display','banner','cpm'), 'Display',
326
+ lower(utm_medium) IN ('affiliate'), 'Affiliate',
327
+ lower(utm_medium) IN ('referral'), 'Referral',
328
+ utm_medium
329
+ )`;
330
+ }
331
+ function channelClassificationExpr() {
332
+ return `multiIf(
333
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
334
+ AND (lower(ifNull(utm_source,'')) IN ('google','goog','bing','yahoo','duckduckgo','ecosia','baidu','yandex')
335
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0),
336
+ 'Paid Search',
337
+ lower(ifNull(utm_medium,'')) IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')
338
+ AND (lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat')
339
+ OR multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat']) > 0),
340
+ 'Paid Social',
341
+ lower(ifNull(utm_medium,'')) IN ('email','e-mail','e_mail'),
342
+ 'Email',
343
+ lower(ifNull(utm_medium,'')) IN ('display','banner','cpm'),
344
+ 'Display',
345
+ lower(ifNull(utm_medium,'')) IN ('affiliate'),
346
+ 'Affiliate',
347
+ multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['google','bing','yahoo','duckduckgo','ecosia','baidu','yandex','search.brave']) > 0
348
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
349
+ 'Organic Search',
350
+ (multiSearchAnyCaseInsensitive(ifNull(referrer,''), ['instagram','facebook','twitter','x.com','t.co','linkedin','youtube','tiktok','pinterest','reddit','snapchat','mastodon','tumblr']) > 0
351
+ OR lower(ifNull(utm_source,'')) IN ('instagram','ig','facebook','fb','twitter','tw','x','linkedin','li','youtube','yt','tiktok','pinterest','reddit','snapchat'))
352
+ AND (ifNull(utm_medium,'') = '' OR lower(utm_medium) NOT IN ('cpc','ppc','paidsearch','paid-search','paid_search','paid')),
353
+ 'Organic Social',
354
+ ifNull(referrer,'') != '' AND length(ifNull(referrer,'')) > 0,
355
+ 'Referral',
356
+ (ifNull(utm_source,'') != '' OR ifNull(utm_medium,'') != '' OR ifNull(utm_campaign,'') != ''),
357
+ 'Other',
358
+ 'Direct'
359
+ )`;
218
360
  }
219
361
  function buildFilterConditions(filters) {
220
362
  if (!filters) return { conditions: [], params: {} };
@@ -226,6 +368,10 @@ function buildFilterConditions(filters) {
226
368
  "device.type": "device_type",
227
369
  "device.browser": "browser",
228
370
  "device.os": "os",
371
+ "device.osVersion": "os_version",
372
+ "device.deviceModel": "device_model",
373
+ "device.deviceBrand": "device_brand",
374
+ "device.appVersion": "app_version",
229
375
  "utm.source": "utm_source",
230
376
  "utm.medium": "utm_medium",
231
377
  "utm.campaign": "utm_campaign",
@@ -242,7 +388,14 @@ function buildFilterConditions(filters) {
242
388
  const conditions = [];
243
389
  const params = {};
244
390
  for (const [key, value] of Object.entries(filters)) {
245
- if (!value || !map[key]) continue;
391
+ if (!value) continue;
392
+ if (key === "channel") {
393
+ const paramKey2 = "f_channel";
394
+ conditions.push(`${channelClassificationExpr()} = {${paramKey2}:String}`);
395
+ params[paramKey2] = value;
396
+ continue;
397
+ }
398
+ if (!map[key]) continue;
246
399
  const paramKey = `f_${key.replace(/[^a-zA-Z0-9]/g, "_")}`;
247
400
  conditions.push(`${map[key]} = {${paramKey}:String}`);
248
401
  params[paramKey] = value;
@@ -273,6 +426,14 @@ var ClickHouseAdapter = class {
273
426
  await this.client.command({
274
427
  query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS conversion_events Nullable(String)`
275
428
  });
429
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS os_version LowCardinality(Nullable(String))` });
430
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_model LowCardinality(Nullable(String))` });
431
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS device_brand LowCardinality(Nullable(String))` });
432
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_version LowCardinality(Nullable(String))` });
433
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS app_build Nullable(String)` });
434
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_name LowCardinality(Nullable(String))` });
435
+ await this.client.command({ query: `ALTER TABLE ${EVENTS_TABLE} ADD COLUMN IF NOT EXISTS sdk_version LowCardinality(Nullable(String))` });
436
+ await this.client.command({ query: `ALTER TABLE ${SITES_TABLE} ADD COLUMN IF NOT EXISTS type LowCardinality(Nullable(String)) DEFAULT 'web'` });
276
437
  }
277
438
  async close() {
278
439
  await this.client.close();
@@ -315,7 +476,14 @@ var ClickHouseAdapter = class {
315
476
  utm_campaign: e.utm?.campaign ?? null,
316
477
  utm_term: e.utm?.term ?? null,
317
478
  utm_content: e.utm?.content ?? null,
318
- ip: e.ip ?? null
479
+ ip: e.ip ?? null,
480
+ os_version: e.device?.osVersion ?? null,
481
+ device_model: e.device?.deviceModel ?? null,
482
+ device_brand: e.device?.deviceBrand ?? null,
483
+ app_version: e.device?.appVersion ?? null,
484
+ app_build: e.device?.appBuild ?? null,
485
+ sdk_name: e.device?.sdkName ?? null,
486
+ sdk_version: e.device?.sdkVersion ?? null
319
487
  }));
320
488
  await this.client.insert({
321
489
  table: EVENTS_TABLE,
@@ -666,15 +834,67 @@ var ClickHouseAdapter = class {
666
834
  total = data.reduce((sum, d) => sum + d.value, 0);
667
835
  break;
668
836
  }
837
+ case "top_os_versions": {
838
+ const rows = await this.queryRows(
839
+ `SELECT concat(os, ' ', ifNull(os_version, '')) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
840
+ WHERE site_id = {siteId:String}
841
+ AND timestamp >= {from:String}
842
+ AND timestamp <= {to:String}
843
+ AND os IS NOT NULL
844
+ AND os_version IS NOT NULL
845
+ ${filterSql}
846
+ GROUP BY key
847
+ ORDER BY value DESC
848
+ LIMIT {limit:UInt32}`,
849
+ { ...params, ...filter.params }
850
+ );
851
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
852
+ total = data.reduce((sum, d) => sum + d.value, 0);
853
+ break;
854
+ }
855
+ case "top_device_models": {
856
+ const rows = await this.queryRows(
857
+ `SELECT trim(concat(ifNull(device_brand, ''), ' ', device_model)) AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
858
+ WHERE site_id = {siteId:String}
859
+ AND timestamp >= {from:String}
860
+ AND timestamp <= {to:String}
861
+ AND device_model IS NOT NULL
862
+ ${filterSql}
863
+ GROUP BY key
864
+ ORDER BY value DESC
865
+ LIMIT {limit:UInt32}`,
866
+ { ...params, ...filter.params }
867
+ );
868
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
869
+ total = data.reduce((sum, d) => sum + d.value, 0);
870
+ break;
871
+ }
872
+ case "top_app_versions": {
873
+ const rows = await this.queryRows(
874
+ `SELECT app_version AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
875
+ WHERE site_id = {siteId:String}
876
+ AND timestamp >= {from:String}
877
+ AND timestamp <= {to:String}
878
+ AND app_version IS NOT NULL
879
+ ${filterSql}
880
+ GROUP BY app_version
881
+ ORDER BY value DESC
882
+ LIMIT {limit:UInt32}`,
883
+ { ...params, ...filter.params }
884
+ );
885
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
886
+ total = data.reduce((sum, d) => sum + d.value, 0);
887
+ break;
888
+ }
669
889
  case "top_utm_sources": {
670
890
  const rows = await this.queryRows(
671
- `SELECT utm_source AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
891
+ `SELECT ${normalizedUtmSourceExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
672
892
  WHERE site_id = {siteId:String}
673
893
  AND timestamp >= {from:String}
674
894
  AND timestamp <= {to:String}
675
895
  AND utm_source IS NOT NULL AND utm_source != ''
676
896
  ${filterSql}
677
- GROUP BY utm_source
897
+ GROUP BY key
678
898
  ORDER BY value DESC
679
899
  LIMIT {limit:UInt32}`,
680
900
  { ...params, ...filter.params }
@@ -685,13 +905,13 @@ var ClickHouseAdapter = class {
685
905
  }
686
906
  case "top_utm_mediums": {
687
907
  const rows = await this.queryRows(
688
- `SELECT utm_medium AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
908
+ `SELECT ${normalizedUtmMediumExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
689
909
  WHERE site_id = {siteId:String}
690
910
  AND timestamp >= {from:String}
691
911
  AND timestamp <= {to:String}
692
912
  AND utm_medium IS NOT NULL AND utm_medium != ''
693
913
  ${filterSql}
694
- GROUP BY utm_medium
914
+ GROUP BY key
695
915
  ORDER BY value DESC
696
916
  LIMIT {limit:UInt32}`,
697
917
  { ...params, ...filter.params }
@@ -717,6 +937,56 @@ var ClickHouseAdapter = class {
717
937
  total = data.reduce((sum, d) => sum + d.value, 0);
718
938
  break;
719
939
  }
940
+ case "top_utm_terms": {
941
+ const rows = await this.queryRows(
942
+ `SELECT utm_term AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
943
+ WHERE site_id = {siteId:String}
944
+ AND timestamp >= {from:String}
945
+ AND timestamp <= {to:String}
946
+ AND utm_term IS NOT NULL AND utm_term != ''
947
+ ${filterSql}
948
+ GROUP BY utm_term
949
+ ORDER BY value DESC
950
+ LIMIT {limit:UInt32}`,
951
+ { ...params, ...filter.params }
952
+ );
953
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
954
+ total = data.reduce((sum, d) => sum + d.value, 0);
955
+ break;
956
+ }
957
+ case "top_utm_contents": {
958
+ const rows = await this.queryRows(
959
+ `SELECT utm_content AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
960
+ WHERE site_id = {siteId:String}
961
+ AND timestamp >= {from:String}
962
+ AND timestamp <= {to:String}
963
+ AND utm_content IS NOT NULL AND utm_content != ''
964
+ ${filterSql}
965
+ GROUP BY utm_content
966
+ ORDER BY value DESC
967
+ LIMIT {limit:UInt32}`,
968
+ { ...params, ...filter.params }
969
+ );
970
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
971
+ total = data.reduce((sum, d) => sum + d.value, 0);
972
+ break;
973
+ }
974
+ case "top_channels": {
975
+ const rows = await this.queryRows(
976
+ `SELECT ${channelClassificationExpr()} AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
977
+ WHERE site_id = {siteId:String}
978
+ AND timestamp >= {from:String}
979
+ AND timestamp <= {to:String}
980
+ ${filterSql}
981
+ GROUP BY key
982
+ ORDER BY value DESC
983
+ LIMIT {limit:UInt32}`,
984
+ { ...params, ...filter.params }
985
+ );
986
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
987
+ total = data.reduce((sum, d) => sum + d.value, 0);
988
+ break;
989
+ }
720
990
  }
721
991
  const result = { metric: q.metric, period, data, total };
722
992
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -747,7 +1017,7 @@ var ClickHouseAdapter = class {
747
1017
  dateTo: params.dateTo
748
1018
  });
749
1019
  const granularity = params.granularity ?? autoGranularity(period);
750
- const bucketFn = this.granularityToClickHouseFunc(granularity);
1020
+ const bucketFn = this.granularityToClickHouseFunc(granularity, params.timezone);
751
1021
  const dateFormat = granularityToDateFormat(granularity);
752
1022
  const filter = buildFilterConditions(params.filters);
753
1023
  const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
@@ -796,38 +1066,41 @@ var ClickHouseAdapter = class {
796
1066
  new Date(dateRange.to),
797
1067
  granularity,
798
1068
  dateFormat,
799
- mappedRows
1069
+ mappedRows,
1070
+ params.timezone
800
1071
  );
801
1072
  return { metric: params.metric, granularity, data };
802
1073
  }
803
- granularityToClickHouseFunc(g) {
1074
+ granularityToClickHouseFunc(g, timezone) {
1075
+ const safeTz = timezone && isValidTimezone(timezone) ? timezone : void 0;
1076
+ const tz = safeTz ? `, '${safeTz}'` : "";
804
1077
  switch (g) {
805
1078
  case "hour":
806
- return "toStartOfHour(timestamp)";
1079
+ return `toStartOfHour(timestamp${tz})`;
807
1080
  case "day":
808
- return "toStartOfDay(timestamp)";
1081
+ return `toStartOfDay(timestamp${tz})`;
809
1082
  case "week":
810
- return "toStartOfWeek(timestamp, 1)";
1083
+ return `toStartOfWeek(timestamp, 1${tz})`;
811
1084
  // 1 = Monday
812
1085
  case "month":
813
- return "toStartOfMonth(timestamp)";
1086
+ return `toStartOfMonth(timestamp${tz})`;
814
1087
  }
815
1088
  }
816
1089
  convertClickHouseBucket(bucket, granularity) {
817
- const date = new Date(bucket);
818
- const y = date.getFullYear();
819
- const m = String(date.getMonth() + 1).padStart(2, "0");
820
- const d = String(date.getDate()).padStart(2, "0");
821
- const h = String(date.getHours()).padStart(2, "0");
1090
+ const date = toUTCDate(bucket);
1091
+ const y = date.getUTCFullYear();
1092
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
1093
+ const d = String(date.getUTCDate()).padStart(2, "0");
1094
+ const h = String(date.getUTCHours()).padStart(2, "0");
822
1095
  switch (granularity) {
823
1096
  case "hour":
824
1097
  return `${y}-${m}-${d}T${h}:00`;
825
1098
  case "day":
826
1099
  return `${y}-${m}-${d}`;
827
1100
  case "week": {
828
- const jan4 = new Date(y, 0, 4);
829
- const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
830
- const jan4Day = jan4.getDay() || 7;
1101
+ const jan4 = new Date(Date.UTC(y, 0, 4));
1102
+ const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
1103
+ const jan4Day = jan4.getUTCDay() || 7;
831
1104
  const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
832
1105
  return `${y}-W${String(weekNum).padStart(2, "0")}`;
833
1106
  }
@@ -856,7 +1129,7 @@ var ClickHouseAdapter = class {
856
1129
  );
857
1130
  const cohortMap = /* @__PURE__ */ new Map();
858
1131
  for (const v of rows) {
859
- const firstDate = new Date(v.first_event);
1132
+ const firstDate = toUTCDate(v.first_event);
860
1133
  const cohortWeek = getISOWeek(firstDate);
861
1134
  if (!cohortMap.has(cohortWeek)) {
862
1135
  cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
@@ -864,7 +1137,7 @@ var ClickHouseAdapter = class {
864
1137
  const cohort = cohortMap.get(cohortWeek);
865
1138
  cohort.visitors.add(v.visitor_id);
866
1139
  const eventWeeks = (Array.isArray(v.active_weeks) ? v.active_weeks : []).map((w) => {
867
- const d = new Date(w);
1140
+ const d = toUTCDate(w);
868
1141
  return getISOWeek(d);
869
1142
  });
870
1143
  for (const w of eventWeeks) {
@@ -1031,8 +1304,8 @@ var ClickHouseAdapter = class {
1031
1304
  visitorId: String(u.visitor_id),
1032
1305
  userId: u.userId ? String(u.userId) : void 0,
1033
1306
  traits: this.parseJSON(u.traits),
1034
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1035
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1307
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1308
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1036
1309
  totalEvents: Number(u.totalEvents),
1037
1310
  totalPageviews: Number(u.totalPageviews),
1038
1311
  totalSessions: Number(u.totalSessions),
@@ -1119,7 +1392,7 @@ var ClickHouseAdapter = class {
1119
1392
  async getMergedUserDetail(siteId, userId, visitorIds) {
1120
1393
  const rows = await this.queryRows(
1121
1394
  `SELECT
1122
- anyLast(visitor_id) AS visitor_id,
1395
+ anyLast(visitor_id) AS last_visitor_id,
1123
1396
  anyLast(traits) AS traits,
1124
1397
  min(timestamp) AS firstSeen,
1125
1398
  max(timestamp) AS lastSeen,
@@ -1151,12 +1424,12 @@ var ClickHouseAdapter = class {
1151
1424
  if (rows.length === 0) return null;
1152
1425
  const u = rows[0];
1153
1426
  return {
1154
- visitorId: String(u.visitor_id),
1427
+ visitorId: String(u.last_visitor_id),
1155
1428
  visitorIds,
1156
1429
  userId,
1157
1430
  traits: this.parseJSON(u.traits),
1158
- firstSeen: new Date(String(u.firstSeen)).toISOString(),
1159
- lastSeen: new Date(String(u.lastSeen)).toISOString(),
1431
+ firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
1432
+ lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
1160
1433
  totalEvents: Number(u.totalEvents),
1161
1434
  totalPageviews: Number(u.totalPageviews),
1162
1435
  totalSessions: Number(u.totalSessions),
@@ -1240,6 +1513,7 @@ var ClickHouseAdapter = class {
1240
1513
  siteId: generateSiteId(),
1241
1514
  secretKey: generateSecretKey(),
1242
1515
  name: data.name,
1516
+ type: data.type ?? "web",
1243
1517
  domain: data.domain,
1244
1518
  allowedOrigins: data.allowedOrigins,
1245
1519
  conversionEvents: data.conversionEvents,
@@ -1252,6 +1526,7 @@ var ClickHouseAdapter = class {
1252
1526
  site_id: site.siteId,
1253
1527
  secret_key: site.secretKey,
1254
1528
  name: site.name,
1529
+ type: site.type ?? "web",
1255
1530
  domain: site.domain ?? null,
1256
1531
  allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
1257
1532
  conversion_events: site.conversionEvents ? JSON.stringify(site.conversionEvents) : null,
@@ -1266,7 +1541,7 @@ var ClickHouseAdapter = class {
1266
1541
  }
1267
1542
  async getSite(siteId) {
1268
1543
  const rows = await this.queryRows(
1269
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1544
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1270
1545
  FROM ${SITES_TABLE} FINAL
1271
1546
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1272
1547
  { siteId }
@@ -1275,7 +1550,7 @@ var ClickHouseAdapter = class {
1275
1550
  }
1276
1551
  async getSiteBySecret(secretKey) {
1277
1552
  const rows = await this.queryRows(
1278
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1553
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1279
1554
  FROM ${SITES_TABLE} FINAL
1280
1555
  WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
1281
1556
  { secretKey }
@@ -1284,7 +1559,7 @@ var ClickHouseAdapter = class {
1284
1559
  }
1285
1560
  async listSites() {
1286
1561
  const rows = await this.queryRows(
1287
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at
1562
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at
1288
1563
  FROM ${SITES_TABLE} FINAL
1289
1564
  WHERE is_deleted = 0
1290
1565
  ORDER BY created_at DESC`,
@@ -1294,7 +1569,7 @@ var ClickHouseAdapter = class {
1294
1569
  }
1295
1570
  async updateSite(siteId, data) {
1296
1571
  const currentRows = await this.queryRows(
1297
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, updated_at, version
1572
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, updated_at, version
1298
1573
  FROM ${SITES_TABLE} FINAL
1299
1574
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1300
1575
  { siteId }
@@ -1306,6 +1581,7 @@ var ClickHouseAdapter = class {
1306
1581
  const nowCH = toCHDateTime(now);
1307
1582
  const newVersion = Number(current.version) + 1;
1308
1583
  const newName = data.name !== void 0 ? data.name : String(current.name);
1584
+ const newType = data.type !== void 0 ? data.type : current.type ? String(current.type) : "web";
1309
1585
  const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
1310
1586
  const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
1311
1587
  const newConversions = data.conversionEvents !== void 0 ? data.conversionEvents.length > 0 ? JSON.stringify(data.conversionEvents) : null : current.conversion_events ? String(current.conversion_events) : null;
@@ -1315,6 +1591,7 @@ var ClickHouseAdapter = class {
1315
1591
  site_id: String(current.site_id),
1316
1592
  secret_key: String(current.secret_key),
1317
1593
  name: newName,
1594
+ type: newType,
1318
1595
  domain: newDomain,
1319
1596
  allowed_origins: newOrigins,
1320
1597
  conversion_events: newConversions,
@@ -1329,6 +1606,7 @@ var ClickHouseAdapter = class {
1329
1606
  siteId: String(current.site_id),
1330
1607
  secretKey: String(current.secret_key),
1331
1608
  name: newName,
1609
+ type: newType,
1332
1610
  domain: newDomain ?? void 0,
1333
1611
  allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
1334
1612
  conversionEvents: newConversions ? JSON.parse(newConversions) : void 0,
@@ -1338,7 +1616,7 @@ var ClickHouseAdapter = class {
1338
1616
  }
1339
1617
  async deleteSite(siteId) {
1340
1618
  const currentRows = await this.queryRows(
1341
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1619
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1342
1620
  FROM ${SITES_TABLE} FINAL
1343
1621
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1344
1622
  { siteId }
@@ -1352,6 +1630,7 @@ var ClickHouseAdapter = class {
1352
1630
  site_id: String(current.site_id),
1353
1631
  secret_key: String(current.secret_key),
1354
1632
  name: String(current.name),
1633
+ type: current.type ? String(current.type) : "web",
1355
1634
  domain: current.domain ? String(current.domain) : null,
1356
1635
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1357
1636
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1366,7 +1645,7 @@ var ClickHouseAdapter = class {
1366
1645
  }
1367
1646
  async regenerateSecret(siteId) {
1368
1647
  const currentRows = await this.queryRows(
1369
- `SELECT site_id, secret_key, name, domain, allowed_origins, conversion_events, created_at, version
1648
+ `SELECT site_id, secret_key, name, type, domain, allowed_origins, conversion_events, created_at, version
1370
1649
  FROM ${SITES_TABLE} FINAL
1371
1650
  WHERE site_id = {siteId:String} AND is_deleted = 0`,
1372
1651
  { siteId }
@@ -1383,6 +1662,7 @@ var ClickHouseAdapter = class {
1383
1662
  site_id: String(current.site_id),
1384
1663
  secret_key: newSecret,
1385
1664
  name: String(current.name),
1665
+ type: current.type ? String(current.type) : "web",
1386
1666
  domain: current.domain ? String(current.domain) : null,
1387
1667
  allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
1388
1668
  conversion_events: current.conversion_events ? String(current.conversion_events) : null,
@@ -1397,6 +1677,7 @@ var ClickHouseAdapter = class {
1397
1677
  siteId: String(current.site_id),
1398
1678
  secretKey: newSecret,
1399
1679
  name: String(current.name),
1680
+ type: current.type ? String(current.type) : "web",
1400
1681
  domain: current.domain ? String(current.domain) : void 0,
1401
1682
  allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
1402
1683
  conversionEvents: current.conversion_events ? JSON.parse(String(current.conversion_events)) : void 0,
@@ -1418,18 +1699,19 @@ var ClickHouseAdapter = class {
1418
1699
  siteId: String(row.site_id),
1419
1700
  secretKey: String(row.secret_key),
1420
1701
  name: String(row.name),
1702
+ type: row.type ? String(row.type) : "web",
1421
1703
  domain: row.domain ? String(row.domain) : void 0,
1422
1704
  allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
1423
1705
  conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
1424
- createdAt: new Date(String(row.created_at)).toISOString(),
1425
- updatedAt: new Date(String(row.updated_at)).toISOString()
1706
+ createdAt: toUTCDate(String(row.created_at)).toISOString(),
1707
+ updatedAt: toUTCDate(String(row.updated_at)).toISOString()
1426
1708
  };
1427
1709
  }
1428
1710
  toEventListItem(row) {
1429
1711
  return {
1430
1712
  id: String(row.event_id ?? ""),
1431
1713
  type: String(row.type),
1432
- timestamp: new Date(String(row.timestamp)).toISOString(),
1714
+ timestamp: toUTCDate(String(row.timestamp)).toISOString(),
1433
1715
  visitorId: String(row.visitor_id),
1434
1716
  sessionId: String(row.session_id),
1435
1717
  url: row.url ? String(row.url) : void 0,
@@ -1481,6 +1763,130 @@ import { MongoClient } from "mongodb";
1481
1763
  var EVENTS_COLLECTION = "litemetrics_events";
1482
1764
  var SITES_COLLECTION = "litemetrics_sites";
1483
1765
  var IDENTITY_MAP_COLLECTION = "litemetrics_identity_map";
1766
+ function normalizedUtmSourceSwitch() {
1767
+ return {
1768
+ $switch: {
1769
+ branches: [
1770
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["ig", "instagram", "instagram.com"]] }, then: "Instagram" },
1771
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["fb", "facebook", "facebook.com", "fb.com"]] }, then: "Facebook" },
1772
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tw", "twitter", "twitter.com", "x", "x.com", "t.co"]] }, then: "X (Twitter)" },
1773
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["li", "linkedin", "linkedin.com"]] }, then: "LinkedIn" },
1774
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["yt", "youtube", "youtube.com"]] }, then: "YouTube" },
1775
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["goog", "google", "google.com"]] }, then: "Google" },
1776
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["gh", "github", "github.com"]] }, then: "GitHub" },
1777
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["reddit", "reddit.com"]] }, then: "Reddit" },
1778
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["pinterest", "pinterest.com"]] }, then: "Pinterest" },
1779
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["tiktok", "tiktok.com"]] }, then: "TikTok" },
1780
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["openai", "chatgpt", "chat.openai.com"]] }, then: "OpenAI" },
1781
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_source", ""] } }, ["perplexity", "perplexity.ai"]] }, then: "Perplexity" }
1782
+ ],
1783
+ default: "$utm_source"
1784
+ }
1785
+ };
1786
+ }
1787
+ function normalizedUtmMediumSwitch() {
1788
+ return {
1789
+ $switch: {
1790
+ branches: [
1791
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"]] }, then: "Paid" },
1792
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["organic"]] }, then: "Organic" },
1793
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["social", "social-media", "social_media"]] }, then: "Social" },
1794
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1795
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["display", "banner", "cpm"]] }, then: "Display" },
1796
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["affiliate"]] }, then: "Affiliate" },
1797
+ { case: { $in: [{ $toLower: { $ifNull: ["$utm_medium", ""] } }, ["referral"]] }, then: "Referral" }
1798
+ ],
1799
+ default: "$utm_medium"
1800
+ }
1801
+ };
1802
+ }
1803
+ var SEARCH_ENGINES = /google|bing|yahoo|duckduckgo|ecosia|baidu|yandex|search\.brave/i;
1804
+ var SOCIAL_NETWORKS = /instagram|facebook|twitter|x\.com|t\.co|linkedin|youtube|tiktok|pinterest|reddit|snapchat|mastodon|tumblr/i;
1805
+ var PAID_MEDIUMS = ["cpc", "ppc", "paidsearch", "paid-search", "paid_search", "paid"];
1806
+ var SOCIAL_SOURCES = ["instagram", "ig", "facebook", "fb", "twitter", "tw", "x", "linkedin", "li", "youtube", "yt", "tiktok", "pinterest", "reddit", "snapchat"];
1807
+ function channelClassificationSwitch() {
1808
+ const lMedium = { $toLower: { $ifNull: ["$utm_medium", ""] } };
1809
+ const lSource = { $toLower: { $ifNull: ["$utm_source", ""] } };
1810
+ const refStr = { $ifNull: ["$referrer", ""] };
1811
+ return {
1812
+ $switch: {
1813
+ branches: [
1814
+ // Paid Search
1815
+ {
1816
+ case: {
1817
+ $and: [
1818
+ { $in: [lMedium, PAID_MEDIUMS] },
1819
+ { $or: [
1820
+ { $in: [lSource, ["google", "goog", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex"]] },
1821
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } }
1822
+ ] }
1823
+ ]
1824
+ },
1825
+ then: "Paid Search"
1826
+ },
1827
+ // Paid Social
1828
+ {
1829
+ case: {
1830
+ $and: [
1831
+ { $in: [lMedium, PAID_MEDIUMS] },
1832
+ { $or: [
1833
+ { $in: [lSource, SOCIAL_SOURCES] },
1834
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } }
1835
+ ] }
1836
+ ]
1837
+ },
1838
+ then: "Paid Social"
1839
+ },
1840
+ // Email
1841
+ { case: { $in: [lMedium, ["email", "e-mail", "e_mail"]] }, then: "Email" },
1842
+ // Display
1843
+ { case: { $in: [lMedium, ["display", "banner", "cpm"]] }, then: "Display" },
1844
+ // Affiliate
1845
+ { case: { $in: [lMedium, ["affiliate"]] }, then: "Affiliate" },
1846
+ // Organic Search
1847
+ {
1848
+ case: {
1849
+ $and: [
1850
+ { $regexMatch: { input: refStr, regex: SEARCH_ENGINES } },
1851
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1852
+ ]
1853
+ },
1854
+ then: "Organic Search"
1855
+ },
1856
+ // Organic Social
1857
+ {
1858
+ case: {
1859
+ $and: [
1860
+ { $or: [
1861
+ { $regexMatch: { input: refStr, regex: SOCIAL_NETWORKS } },
1862
+ { $in: [lSource, SOCIAL_SOURCES] }
1863
+ ] },
1864
+ { $not: [{ $in: [lMedium, PAID_MEDIUMS] }] }
1865
+ ]
1866
+ },
1867
+ then: "Organic Social"
1868
+ },
1869
+ // Referral
1870
+ {
1871
+ case: { $and: [{ $ne: [refStr, ""] }, { $gt: [{ $strLenCP: refStr }, 0] }] },
1872
+ then: "Referral"
1873
+ },
1874
+ // Other (has UTM but no referrer)
1875
+ {
1876
+ case: {
1877
+ $or: [
1878
+ { $and: [{ $ne: [{ $ifNull: ["$utm_source", ""] }, ""] }] },
1879
+ { $and: [{ $ne: [{ $ifNull: ["$utm_medium", ""] }, ""] }] },
1880
+ { $and: [{ $ne: [{ $ifNull: ["$utm_campaign", ""] }, ""] }] }
1881
+ ]
1882
+ },
1883
+ then: "Other"
1884
+ }
1885
+ ],
1886
+ default: "Direct"
1887
+ }
1888
+ };
1889
+ }
1484
1890
  function buildFilterMatch(filters) {
1485
1891
  if (!filters) return {};
1486
1892
  const map = {
@@ -1491,6 +1897,10 @@ function buildFilterMatch(filters) {
1491
1897
  "device.type": "device_type",
1492
1898
  "device.browser": "browser",
1493
1899
  "device.os": "os",
1900
+ "device.osVersion": "os_version",
1901
+ "device.deviceModel": "device_model",
1902
+ "device.deviceBrand": "device_brand",
1903
+ "device.appVersion": "app_version",
1494
1904
  "utm.source": "utm_source",
1495
1905
  "utm.medium": "utm_medium",
1496
1906
  "utm.campaign": "utm_campaign",
@@ -1506,7 +1916,9 @@ function buildFilterMatch(filters) {
1506
1916
  };
1507
1917
  const match = {};
1508
1918
  for (const [key, value] of Object.entries(filters)) {
1509
- if (!value || !map[key]) continue;
1919
+ if (!value) continue;
1920
+ if (key === "channel") continue;
1921
+ if (!map[key]) continue;
1510
1922
  match[map[key]] = value;
1511
1923
  }
1512
1924
  return match;
@@ -1575,6 +1987,13 @@ var MongoDBAdapter = class {
1575
1987
  utm_term: e.utm?.term ?? null,
1576
1988
  utm_content: e.utm?.content ?? null,
1577
1989
  ip: e.ip ?? null,
1990
+ os_version: e.device?.osVersion ?? null,
1991
+ device_model: e.device?.deviceModel ?? null,
1992
+ device_brand: e.device?.deviceBrand ?? null,
1993
+ app_version: e.device?.appVersion ?? null,
1994
+ app_build: e.device?.appBuild ?? null,
1995
+ sdk_name: e.device?.sdkName ?? null,
1996
+ sdk_version: e.device?.sdkVersion ?? null,
1578
1997
  created_at: /* @__PURE__ */ new Date()
1579
1998
  }));
1580
1999
  await this.collection.insertMany(docs);
@@ -1588,12 +2007,22 @@ var MongoDBAdapter = class {
1588
2007
  timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1589
2008
  };
1590
2009
  const filterMatch = buildFilterMatch(q.filters);
2010
+ const matchStages = (extra) => {
2011
+ const stages = [
2012
+ { $match: { ...baseMatch, ...filterMatch, ...extra } }
2013
+ ];
2014
+ if (q.filters?.channel) {
2015
+ stages.push({ $addFields: { _channel: channelClassificationSwitch() } });
2016
+ stages.push({ $match: { _channel: q.filters.channel } });
2017
+ }
2018
+ return stages;
2019
+ };
1591
2020
  let data = [];
1592
2021
  let total = 0;
1593
2022
  switch (q.metric) {
1594
2023
  case "pageviews": {
1595
2024
  const [result2] = await this.collection.aggregate([
1596
- { $match: { ...baseMatch, ...filterMatch, type: "pageview" } },
2025
+ ...matchStages({ type: "pageview" }),
1597
2026
  { $count: "count" }
1598
2027
  ]).toArray();
1599
2028
  total = result2?.count ?? 0;
@@ -1602,7 +2031,7 @@ var MongoDBAdapter = class {
1602
2031
  }
1603
2032
  case "visitors": {
1604
2033
  const [result2] = await this.collection.aggregate([
1605
- { $match: { ...baseMatch, ...filterMatch } },
2034
+ ...matchStages(),
1606
2035
  { $group: { _id: "$visitor_id" } },
1607
2036
  { $count: "count" }
1608
2037
  ]).toArray();
@@ -1612,7 +2041,7 @@ var MongoDBAdapter = class {
1612
2041
  }
1613
2042
  case "sessions": {
1614
2043
  const [result2] = await this.collection.aggregate([
1615
- { $match: { ...baseMatch, ...filterMatch } },
2044
+ ...matchStages(),
1616
2045
  { $group: { _id: "$session_id" } },
1617
2046
  { $count: "count" }
1618
2047
  ]).toArray();
@@ -1622,7 +2051,7 @@ var MongoDBAdapter = class {
1622
2051
  }
1623
2052
  case "events": {
1624
2053
  const [result2] = await this.collection.aggregate([
1625
- { $match: { ...baseMatch, ...filterMatch, type: "event" } },
2054
+ ...matchStages({ type: "event" }),
1626
2055
  { $count: "count" }
1627
2056
  ]).toArray();
1628
2057
  total = result2?.count ?? 0;
@@ -1637,7 +2066,7 @@ var MongoDBAdapter = class {
1637
2066
  break;
1638
2067
  }
1639
2068
  const [result2] = await this.collection.aggregate([
1640
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2069
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1641
2070
  { $count: "count" }
1642
2071
  ]).toArray();
1643
2072
  total = result2?.count ?? 0;
@@ -1646,7 +2075,7 @@ var MongoDBAdapter = class {
1646
2075
  }
1647
2076
  case "top_pages": {
1648
2077
  const rows = await this.collection.aggregate([
1649
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2078
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1650
2079
  { $group: { _id: "$url", value: { $sum: 1 } } },
1651
2080
  { $sort: { value: -1 } },
1652
2081
  { $limit: limit }
@@ -1657,7 +2086,7 @@ var MongoDBAdapter = class {
1657
2086
  }
1658
2087
  case "top_referrers": {
1659
2088
  const rows = await this.collection.aggregate([
1660
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
2089
+ ...matchStages({ type: "pageview", referrer: { $nin: [null, ""] } }),
1661
2090
  { $group: { _id: "$referrer", value: { $sum: 1 } } },
1662
2091
  { $sort: { value: -1 } },
1663
2092
  { $limit: limit }
@@ -1668,7 +2097,7 @@ var MongoDBAdapter = class {
1668
2097
  }
1669
2098
  case "top_countries": {
1670
2099
  const rows = await this.collection.aggregate([
1671
- { $match: { ...baseMatch, ...filterMatch, country: { $ne: null } } },
2100
+ ...matchStages({ country: { $ne: null } }),
1672
2101
  { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1673
2102
  { $project: { _id: 1, value: { $size: "$value" } } },
1674
2103
  { $sort: { value: -1 } },
@@ -1680,7 +2109,7 @@ var MongoDBAdapter = class {
1680
2109
  }
1681
2110
  case "top_cities": {
1682
2111
  const rows = await this.collection.aggregate([
1683
- { $match: { ...baseMatch, ...filterMatch, city: { $ne: null } } },
2112
+ ...matchStages({ city: { $ne: null } }),
1684
2113
  { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1685
2114
  { $project: { _id: 1, value: { $size: "$value" } } },
1686
2115
  { $sort: { value: -1 } },
@@ -1692,7 +2121,7 @@ var MongoDBAdapter = class {
1692
2121
  }
1693
2122
  case "top_events": {
1694
2123
  const rows = await this.collection.aggregate([
1695
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $ne: null } } },
2124
+ ...matchStages({ type: "event", event_name: { $ne: null } }),
1696
2125
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1697
2126
  { $sort: { value: -1 } },
1698
2127
  { $limit: limit }
@@ -1709,7 +2138,7 @@ var MongoDBAdapter = class {
1709
2138
  break;
1710
2139
  }
1711
2140
  const rows = await this.collection.aggregate([
1712
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_name: { $in: conversionEvents } } },
2141
+ ...matchStages({ type: "event", event_name: { $in: conversionEvents } }),
1713
2142
  { $group: { _id: "$event_name", value: { $sum: 1 } } },
1714
2143
  { $sort: { value: -1 } },
1715
2144
  { $limit: limit }
@@ -1720,7 +2149,7 @@ var MongoDBAdapter = class {
1720
2149
  }
1721
2150
  case "top_exit_pages": {
1722
2151
  const rows = await this.collection.aggregate([
1723
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2152
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1724
2153
  { $sort: { timestamp: 1 } },
1725
2154
  { $group: { _id: "$session_id", url: { $last: "$url" } } },
1726
2155
  { $group: { _id: "$url", value: { $sum: 1 } } },
@@ -1733,7 +2162,7 @@ var MongoDBAdapter = class {
1733
2162
  }
1734
2163
  case "top_transitions": {
1735
2164
  const rows = await this.collection.aggregate([
1736
- { $match: { ...baseMatch, ...filterMatch, type: "pageview", url: { $ne: null } } },
2165
+ ...matchStages({ type: "pageview", url: { $ne: null } }),
1737
2166
  {
1738
2167
  $setWindowFields: {
1739
2168
  partitionBy: "$session_id",
@@ -1754,7 +2183,7 @@ var MongoDBAdapter = class {
1754
2183
  }
1755
2184
  case "top_scroll_pages": {
1756
2185
  const rows = await this.collection.aggregate([
1757
- { $match: { ...baseMatch, ...filterMatch, type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } } },
2186
+ ...matchStages({ type: "event", event_subtype: "scroll_depth", page_path: { $ne: null } }),
1758
2187
  { $group: { _id: "$page_path", value: { $sum: 1 } } },
1759
2188
  { $sort: { value: -1 } },
1760
2189
  { $limit: limit }
@@ -1765,15 +2194,11 @@ var MongoDBAdapter = class {
1765
2194
  }
1766
2195
  case "top_button_clicks": {
1767
2196
  const rows = await this.collection.aggregate([
1768
- {
1769
- $match: {
1770
- ...baseMatch,
1771
- ...filterMatch,
1772
- type: "event",
1773
- event_subtype: "button_click",
1774
- $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
1775
- }
1776
- },
2197
+ ...matchStages({
2198
+ type: "event",
2199
+ event_subtype: "button_click",
2200
+ $or: [{ element_text: { $ne: null } }, { element_selector: { $ne: null } }]
2201
+ }),
1777
2202
  { $group: { _id: { $ifNull: ["$element_text", "$element_selector"] }, value: { $sum: 1 } } },
1778
2203
  { $sort: { value: -1 } },
1779
2204
  { $limit: limit }
@@ -1784,15 +2209,11 @@ var MongoDBAdapter = class {
1784
2209
  }
1785
2210
  case "top_link_targets": {
1786
2211
  const rows = await this.collection.aggregate([
1787
- {
1788
- $match: {
1789
- ...baseMatch,
1790
- ...filterMatch,
1791
- type: "event",
1792
- event_subtype: { $in: ["link_click", "outbound_click"] },
1793
- target_url_path: { $ne: null }
1794
- }
1795
- },
2212
+ ...matchStages({
2213
+ type: "event",
2214
+ event_subtype: { $in: ["link_click", "outbound_click"] },
2215
+ target_url_path: { $ne: null }
2216
+ }),
1796
2217
  { $group: { _id: "$target_url_path", value: { $sum: 1 } } },
1797
2218
  { $sort: { value: -1 } },
1798
2219
  { $limit: limit }
@@ -1803,7 +2224,7 @@ var MongoDBAdapter = class {
1803
2224
  }
1804
2225
  case "top_devices": {
1805
2226
  const rows = await this.collection.aggregate([
1806
- { $match: { ...baseMatch, ...filterMatch, device_type: { $ne: null } } },
2227
+ ...matchStages({ device_type: { $ne: null } }),
1807
2228
  { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1808
2229
  { $project: { _id: 1, value: { $size: "$value" } } },
1809
2230
  { $sort: { value: -1 } },
@@ -1815,7 +2236,7 @@ var MongoDBAdapter = class {
1815
2236
  }
1816
2237
  case "top_browsers": {
1817
2238
  const rows = await this.collection.aggregate([
1818
- { $match: { ...baseMatch, ...filterMatch, browser: { $ne: null } } },
2239
+ ...matchStages({ browser: { $ne: null } }),
1819
2240
  { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1820
2241
  { $project: { _id: 1, value: { $size: "$value" } } },
1821
2242
  { $sort: { value: -1 } },
@@ -1827,7 +2248,7 @@ var MongoDBAdapter = class {
1827
2248
  }
1828
2249
  case "top_os": {
1829
2250
  const rows = await this.collection.aggregate([
1830
- { $match: { ...baseMatch, ...filterMatch, os: { $ne: null } } },
2251
+ ...matchStages({ os: { $ne: null } }),
1831
2252
  { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1832
2253
  { $project: { _id: 1, value: { $size: "$value" } } },
1833
2254
  { $sort: { value: -1 } },
@@ -1837,10 +2258,47 @@ var MongoDBAdapter = class {
1837
2258
  total = data.reduce((sum, d) => sum + d.value, 0);
1838
2259
  break;
1839
2260
  }
2261
+ case "top_os_versions": {
2262
+ const rows = await this.collection.aggregate([
2263
+ ...matchStages({ os: { $ne: null }, os_version: { $ne: null } }),
2264
+ { $group: { _id: { $concat: ["$os", " ", "$os_version"] }, value: { $addToSet: "$visitor_id" } } },
2265
+ { $project: { _id: 1, value: { $size: "$value" } } },
2266
+ { $sort: { value: -1 } },
2267
+ { $limit: limit }
2268
+ ]).toArray();
2269
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2270
+ total = data.reduce((sum, d) => sum + d.value, 0);
2271
+ break;
2272
+ }
2273
+ case "top_device_models": {
2274
+ const rows = await this.collection.aggregate([
2275
+ ...matchStages({ device_model: { $ne: null } }),
2276
+ { $group: { _id: { $trim: { input: { $concat: [{ $ifNull: ["$device_brand", ""] }, " ", "$device_model"] } } }, value: { $addToSet: "$visitor_id" } } },
2277
+ { $project: { _id: 1, value: { $size: "$value" } } },
2278
+ { $sort: { value: -1 } },
2279
+ { $limit: limit }
2280
+ ]).toArray();
2281
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2282
+ total = data.reduce((sum, d) => sum + d.value, 0);
2283
+ break;
2284
+ }
2285
+ case "top_app_versions": {
2286
+ const rows = await this.collection.aggregate([
2287
+ ...matchStages({ app_version: { $ne: null } }),
2288
+ { $group: { _id: "$app_version", value: { $addToSet: "$visitor_id" } } },
2289
+ { $project: { _id: 1, value: { $size: "$value" } } },
2290
+ { $sort: { value: -1 } },
2291
+ { $limit: limit }
2292
+ ]).toArray();
2293
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2294
+ total = data.reduce((sum, d) => sum + d.value, 0);
2295
+ break;
2296
+ }
1840
2297
  case "top_utm_sources": {
1841
2298
  const rows = await this.collection.aggregate([
1842
- { $match: { ...baseMatch, ...filterMatch, utm_source: { $nin: [null, ""] } } },
1843
- { $group: { _id: "$utm_source", value: { $addToSet: "$visitor_id" } } },
2299
+ ...matchStages({ utm_source: { $nin: [null, ""] } }),
2300
+ { $addFields: { _normalized_source: normalizedUtmSourceSwitch() } },
2301
+ { $group: { _id: "$_normalized_source", value: { $addToSet: "$visitor_id" } } },
1844
2302
  { $project: { _id: 1, value: { $size: "$value" } } },
1845
2303
  { $sort: { value: -1 } },
1846
2304
  { $limit: limit }
@@ -1851,8 +2309,9 @@ var MongoDBAdapter = class {
1851
2309
  }
1852
2310
  case "top_utm_mediums": {
1853
2311
  const rows = await this.collection.aggregate([
1854
- { $match: { ...baseMatch, ...filterMatch, utm_medium: { $nin: [null, ""] } } },
1855
- { $group: { _id: "$utm_medium", value: { $addToSet: "$visitor_id" } } },
2312
+ ...matchStages({ utm_medium: { $nin: [null, ""] } }),
2313
+ { $addFields: { _normalized_medium: normalizedUtmMediumSwitch() } },
2314
+ { $group: { _id: "$_normalized_medium", value: { $addToSet: "$visitor_id" } } },
1856
2315
  { $project: { _id: 1, value: { $size: "$value" } } },
1857
2316
  { $sort: { value: -1 } },
1858
2317
  { $limit: limit }
@@ -1863,7 +2322,7 @@ var MongoDBAdapter = class {
1863
2322
  }
1864
2323
  case "top_utm_campaigns": {
1865
2324
  const rows = await this.collection.aggregate([
1866
- { $match: { ...baseMatch, ...filterMatch, utm_campaign: { $nin: [null, ""] } } },
2325
+ ...matchStages({ utm_campaign: { $nin: [null, ""] } }),
1867
2326
  { $group: { _id: "$utm_campaign", value: { $addToSet: "$visitor_id" } } },
1868
2327
  { $project: { _id: 1, value: { $size: "$value" } } },
1869
2328
  { $sort: { value: -1 } },
@@ -1873,6 +2332,43 @@ var MongoDBAdapter = class {
1873
2332
  total = data.reduce((sum, d) => sum + d.value, 0);
1874
2333
  break;
1875
2334
  }
2335
+ case "top_utm_terms": {
2336
+ const rows = await this.collection.aggregate([
2337
+ ...matchStages({ utm_term: { $nin: [null, ""] } }),
2338
+ { $group: { _id: "$utm_term", value: { $addToSet: "$visitor_id" } } },
2339
+ { $project: { _id: 1, value: { $size: "$value" } } },
2340
+ { $sort: { value: -1 } },
2341
+ { $limit: limit }
2342
+ ]).toArray();
2343
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2344
+ total = data.reduce((sum, d) => sum + d.value, 0);
2345
+ break;
2346
+ }
2347
+ case "top_utm_contents": {
2348
+ const rows = await this.collection.aggregate([
2349
+ ...matchStages({ utm_content: { $nin: [null, ""] } }),
2350
+ { $group: { _id: "$utm_content", value: { $addToSet: "$visitor_id" } } },
2351
+ { $project: { _id: 1, value: { $size: "$value" } } },
2352
+ { $sort: { value: -1 } },
2353
+ { $limit: limit }
2354
+ ]).toArray();
2355
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2356
+ total = data.reduce((sum, d) => sum + d.value, 0);
2357
+ break;
2358
+ }
2359
+ case "top_channels": {
2360
+ const rows = await this.collection.aggregate([
2361
+ ...matchStages(),
2362
+ { $addFields: { _channel: channelClassificationSwitch() } },
2363
+ { $group: { _id: "$_channel", value: { $addToSet: "$visitor_id" } } },
2364
+ { $project: { _id: 1, value: { $size: "$value" } } },
2365
+ { $sort: { value: -1 } },
2366
+ { $limit: limit }
2367
+ ]).toArray();
2368
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
2369
+ total = data.reduce((sum, d) => sum + d.value, 0);
2370
+ break;
2371
+ }
1876
2372
  }
1877
2373
  const result = { metric: q.metric, period, data, total };
1878
2374
  if (q.compare && ["pageviews", "visitors", "sessions", "events", "conversions"].includes(q.metric)) {
@@ -2335,7 +2831,18 @@ var MongoDBAdapter = class {
2335
2831
  userId: doc.user_id ?? void 0,
2336
2832
  traits: doc.traits ?? void 0,
2337
2833
  geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
2338
- device: doc.device_type ? { type: doc.device_type, browser: doc.browser ?? "", os: doc.os ?? "" } : void 0,
2834
+ device: doc.device_type ? {
2835
+ type: doc.device_type,
2836
+ browser: doc.browser ?? "",
2837
+ os: doc.os ?? "",
2838
+ osVersion: doc.os_version ?? void 0,
2839
+ deviceModel: doc.device_model ?? void 0,
2840
+ deviceBrand: doc.device_brand ?? void 0,
2841
+ appVersion: doc.app_version ?? void 0,
2842
+ appBuild: doc.app_build ?? void 0,
2843
+ sdkName: doc.sdk_name ?? void 0,
2844
+ sdkVersion: doc.sdk_version ?? void 0
2845
+ } : void 0,
2339
2846
  language: doc.language ?? void 0,
2340
2847
  utm: doc.utm_source ? {
2341
2848
  source: doc.utm_source ?? void 0,
@@ -2353,6 +2860,7 @@ var MongoDBAdapter = class {
2353
2860
  site_id: generateSiteId(),
2354
2861
  secret_key: generateSecretKey(),
2355
2862
  name: data.name,
2863
+ type: data.type ?? "web",
2356
2864
  domain: data.domain ?? null,
2357
2865
  allowed_origins: data.allowedOrigins ?? null,
2358
2866
  conversion_events: data.conversionEvents ?? null,
@@ -2377,6 +2885,7 @@ var MongoDBAdapter = class {
2377
2885
  async updateSite(siteId, data) {
2378
2886
  const updates = { updated_at: /* @__PURE__ */ new Date() };
2379
2887
  if (data.name !== void 0) updates.name = data.name;
2888
+ if (data.type !== void 0) updates.type = data.type;
2380
2889
  if (data.domain !== void 0) updates.domain = data.domain || null;
2381
2890
  if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
2382
2891
  if (data.conversionEvents !== void 0) updates.conversion_events = data.conversionEvents.length > 0 ? data.conversionEvents : null;
@@ -2408,6 +2917,7 @@ var MongoDBAdapter = class {
2408
2917
  siteId: doc.site_id,
2409
2918
  secretKey: doc.secret_key,
2410
2919
  name: doc.name,
2920
+ type: doc.type ?? "web",
2411
2921
  domain: doc.domain ?? void 0,
2412
2922
  allowedOrigins: doc.allowed_origins ?? void 0,
2413
2923
  conversionEvents: doc.conversion_events ?? void 0,
@@ -2676,9 +3186,26 @@ async function createCollector(config) {
2676
3186
  return false;
2677
3187
  }
2678
3188
  function enrichEvents(events, ip, userAgent) {
2679
- const device = parseUserAgent(userAgent);
3189
+ const uaDevice = parseUserAgent(userAgent);
2680
3190
  return events.map((event) => {
2681
3191
  const geo = resolveGeo(ip, event.timezone);
3192
+ let device;
3193
+ if (event.mobile?.platform) {
3194
+ device = {
3195
+ type: "mobile",
3196
+ browser: "App",
3197
+ os: event.mobile.platform === "ios" ? "iOS" : "Android",
3198
+ osVersion: event.mobile.osVersion,
3199
+ deviceModel: event.mobile.deviceModel,
3200
+ deviceBrand: event.mobile.deviceBrand,
3201
+ appVersion: event.mobile.appVersion,
3202
+ appBuild: event.mobile.appBuild,
3203
+ sdkName: event.mobile.sdkName,
3204
+ sdkVersion: event.mobile.sdkVersion
3205
+ };
3206
+ } else {
3207
+ device = uaDevice;
3208
+ }
2682
3209
  return { ...event, ip, geo, device };
2683
3210
  });
2684
3211
  }
@@ -2738,6 +3265,22 @@ async function createCollector(config) {
2738
3265
  }
2739
3266
  return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
2740
3267
  }
3268
+ function extractRequestHostname(req) {
3269
+ const headerValue = (value) => {
3270
+ if (Array.isArray(value)) return value[0];
3271
+ if (typeof value === "string") return value;
3272
+ return void 0;
3273
+ };
3274
+ const origin = headerValue(req.headers?.origin);
3275
+ const referer = headerValue(req.headers?.referer) || headerValue(req.headers?.referrer);
3276
+ const raw = origin ?? referer;
3277
+ if (!raw || raw === "null") return void 0;
3278
+ try {
3279
+ return new URL(raw).hostname.toLowerCase();
3280
+ } catch {
3281
+ return void 0;
3282
+ }
3283
+ }
2741
3284
  function handler() {
2742
3285
  return async (req, res) => {
2743
3286
  res.setHeader?.("Access-Control-Allow-Origin", "*");
@@ -2763,6 +3306,12 @@ async function createCollector(config) {
2763
3306
  sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
2764
3307
  return;
2765
3308
  }
3309
+ const siteIds = new Set(payload.events.map((event) => event.siteId).filter(Boolean));
3310
+ if (siteIds.size !== 1) {
3311
+ sendJson(res, 200, { ok: true });
3312
+ return;
3313
+ }
3314
+ const siteId = Array.from(siteIds)[0];
2766
3315
  const userAgent = req.headers?.["user-agent"] || "";
2767
3316
  if (isBot(userAgent)) {
2768
3317
  sendJson(res, 200, { ok: true });
@@ -2770,26 +3319,15 @@ async function createCollector(config) {
2770
3319
  }
2771
3320
  const ip = extractIp(req);
2772
3321
  const enriched = enrichEvents(payload.events, ip, userAgent);
2773
- const siteId = enriched[0]?.siteId;
2774
- if (siteId) {
2775
- const site = await db.getSite(siteId);
2776
- if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
2777
- const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
2778
- const filtered = enriched.filter((event) => {
2779
- if (!event.url) return true;
2780
- try {
2781
- const hostname = new URL(event.url).hostname.toLowerCase();
2782
- return allowed.has(hostname);
2783
- } catch {
2784
- return true;
2785
- }
2786
- });
2787
- if (filtered.length === 0) {
2788
- sendJson(res, 200, { ok: true });
2789
- return;
2790
- }
2791
- await processIdentity(filtered);
2792
- await db.insertEvents(filtered);
3322
+ const site = await db.getSite(siteId);
3323
+ if (site?.allowedOrigins && site.allowedOrigins.length > 0) {
3324
+ const requestHostname = extractRequestHostname(req);
3325
+ if (!requestHostname) {
3326
+ sendJson(res, 200, { ok: true });
3327
+ return;
3328
+ }
3329
+ const allowed = new Set(site.allowedOrigins.map((h) => h.toLowerCase()));
3330
+ if (!allowed.has(requestHostname)) {
2793
3331
  sendJson(res, 200, { ok: true });
2794
3332
  return;
2795
3333
  }
@@ -2829,7 +3367,8 @@ async function createCollector(config) {
2829
3367
  dateFrom: params.dateFrom,
2830
3368
  dateTo: params.dateTo,
2831
3369
  granularity: q.granularity,
2832
- filters: q.filters ? JSON.parse(q.filters) : void 0
3370
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
3371
+ timezone: params.timezone
2833
3372
  };
2834
3373
  if (tsParams.metric === "conversions") {
2835
3374
  const site = await db.getSite(params.siteId);
@@ -3131,19 +3670,6 @@ async function parseBody(req) {
3131
3670
  req.on("error", reject);
3132
3671
  });
3133
3672
  }
3134
- function extractQueryParams(req) {
3135
- const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
3136
- return {
3137
- siteId: q.siteId,
3138
- metric: q.metric,
3139
- period: q.period,
3140
- dateFrom: q.dateFrom,
3141
- dateTo: q.dateTo,
3142
- limit: q.limit ? parseInt(q.limit, 10) : void 0,
3143
- filters: q.filters ? JSON.parse(q.filters) : void 0,
3144
- compare: q.compare === "true" || q.compare === "1"
3145
- };
3146
- }
3147
3673
  function sendJson(res, status, body) {
3148
3674
  if (typeof res.status === "function" && typeof res.json === "function") {
3149
3675
  res.status(status).json(body);