@litemetrics/node 0.1.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.cjs ADDED
@@ -0,0 +1,2120 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ ClickHouseAdapter: () => ClickHouseAdapter,
34
+ MongoDBAdapter: () => MongoDBAdapter,
35
+ createCollector: () => createCollector
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/adapters/clickhouse.ts
40
+ var import_client = require("@clickhouse/client");
41
+
42
+ // src/adapters/utils.ts
43
+ var import_crypto = require("crypto");
44
+ function resolvePeriod(q) {
45
+ const now = /* @__PURE__ */ new Date();
46
+ const period = q.period ?? "7d";
47
+ if (period === "custom" && q.dateFrom && q.dateTo) {
48
+ return { dateRange: { from: q.dateFrom, to: q.dateTo }, period };
49
+ }
50
+ const to = now.toISOString();
51
+ let from;
52
+ switch (period) {
53
+ case "1h":
54
+ from = new Date(now.getTime() - 60 * 60 * 1e3);
55
+ break;
56
+ case "24h":
57
+ from = new Date(now.getTime() - 24 * 60 * 60 * 1e3);
58
+ break;
59
+ case "7d":
60
+ from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
61
+ break;
62
+ case "30d":
63
+ from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
64
+ break;
65
+ case "90d":
66
+ from = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1e3);
67
+ break;
68
+ default:
69
+ from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1e3);
70
+ break;
71
+ }
72
+ return { dateRange: { from: from.toISOString(), to }, period };
73
+ }
74
+ function previousPeriodRange(currentRange) {
75
+ const from = new Date(currentRange.from);
76
+ const to = new Date(currentRange.to);
77
+ const duration = to.getTime() - from.getTime();
78
+ const prevTo = new Date(from.getTime() - 1);
79
+ const prevFrom = new Date(prevTo.getTime() - duration);
80
+ return { from: prevFrom.toISOString(), to: prevTo.toISOString() };
81
+ }
82
+ function autoGranularity(period) {
83
+ switch (period) {
84
+ case "1h":
85
+ return "hour";
86
+ case "24h":
87
+ return "hour";
88
+ case "7d":
89
+ return "day";
90
+ case "30d":
91
+ return "day";
92
+ case "90d":
93
+ return "week";
94
+ default:
95
+ return "day";
96
+ }
97
+ }
98
+ function granularityToDateFormat(g) {
99
+ switch (g) {
100
+ case "hour":
101
+ return "%Y-%m-%dT%H:00";
102
+ case "day":
103
+ return "%Y-%m-%d";
104
+ case "week":
105
+ return "%G-W%V";
106
+ case "month":
107
+ return "%Y-%m";
108
+ }
109
+ }
110
+ function fillBuckets(from, to, granularity, dateFormat, rows) {
111
+ const map = new Map(rows.map((r) => [r._id, r.value]));
112
+ const points = [];
113
+ const current = new Date(from);
114
+ if (granularity === "hour") {
115
+ current.setMinutes(0, 0, 0);
116
+ } else if (granularity === "day") {
117
+ current.setHours(0, 0, 0, 0);
118
+ } else if (granularity === "week") {
119
+ const day = current.getDay();
120
+ const diff = day === 0 ? -6 : 1 - day;
121
+ current.setDate(current.getDate() + diff);
122
+ current.setHours(0, 0, 0, 0);
123
+ } else if (granularity === "month") {
124
+ current.setDate(1);
125
+ current.setHours(0, 0, 0, 0);
126
+ }
127
+ while (current <= to) {
128
+ const key = formatDateBucket(current, dateFormat);
129
+ points.push({ date: current.toISOString(), value: map.get(key) ?? 0 });
130
+ if (granularity === "hour") {
131
+ current.setHours(current.getHours() + 1);
132
+ } else if (granularity === "day") {
133
+ current.setDate(current.getDate() + 1);
134
+ } else if (granularity === "week") {
135
+ current.setDate(current.getDate() + 7);
136
+ } else if (granularity === "month") {
137
+ current.setMonth(current.getMonth() + 1);
138
+ }
139
+ }
140
+ return points;
141
+ }
142
+ function formatDateBucket(date, format) {
143
+ const y = date.getFullYear();
144
+ const m = String(date.getMonth() + 1).padStart(2, "0");
145
+ const d = String(date.getDate()).padStart(2, "0");
146
+ const h = String(date.getHours()).padStart(2, "0");
147
+ if (format === "%Y-%m-%dT%H:00") return `${y}-${m}-${d}T${h}:00`;
148
+ if (format === "%Y-%m-%d") return `${y}-${m}-${d}`;
149
+ if (format === "%Y-%m") return `${y}-${m}`;
150
+ if (format === "%G-W%V") {
151
+ const jan4 = new Date(y, 0, 4);
152
+ const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
153
+ const jan4Day = jan4.getDay() || 7;
154
+ const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
155
+ return `${y}-W${String(weekNum).padStart(2, "0")}`;
156
+ }
157
+ return date.toISOString();
158
+ }
159
+ function getISOWeek(date) {
160
+ const y = date.getFullYear();
161
+ const jan4 = new Date(y, 0, 4);
162
+ const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
163
+ const jan4Day = jan4.getDay() || 7;
164
+ const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
165
+ return `${y}-W${String(weekNum).padStart(2, "0")}`;
166
+ }
167
+ function generateSiteId() {
168
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
169
+ const bytes = (0, import_crypto.randomBytes)(12);
170
+ let id = "";
171
+ for (let i = 0; i < 12; i++) id += chars[bytes[i] % chars.length];
172
+ return `site_${id}`;
173
+ }
174
+ function generateSecretKey() {
175
+ return `sk_${(0, import_crypto.randomBytes)(32).toString("hex")}`;
176
+ }
177
+
178
+ // src/adapters/clickhouse.ts
179
+ var EVENTS_TABLE = "litemetrics_events";
180
+ var SITES_TABLE = "litemetrics_sites";
181
+ var CREATE_EVENTS_TABLE = `
182
+ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
183
+ event_id UUID DEFAULT generateUUIDv4(),
184
+ site_id LowCardinality(String),
185
+ type LowCardinality(String),
186
+ timestamp DateTime64(3),
187
+ session_id String,
188
+ visitor_id String,
189
+ url Nullable(String),
190
+ referrer Nullable(String),
191
+ title Nullable(String),
192
+ event_name Nullable(String),
193
+ properties Nullable(String),
194
+ user_id Nullable(String),
195
+ traits Nullable(String),
196
+ country LowCardinality(Nullable(String)),
197
+ city Nullable(String),
198
+ region Nullable(String),
199
+ device_type LowCardinality(Nullable(String)),
200
+ browser LowCardinality(Nullable(String)),
201
+ os LowCardinality(Nullable(String)),
202
+ language LowCardinality(Nullable(String)),
203
+ timezone Nullable(String),
204
+ screen_width Nullable(UInt16),
205
+ screen_height Nullable(UInt16),
206
+ utm_source Nullable(String),
207
+ utm_medium Nullable(String),
208
+ utm_campaign Nullable(String),
209
+ utm_term Nullable(String),
210
+ utm_content Nullable(String),
211
+ ip Nullable(String),
212
+ created_at DateTime64(3) DEFAULT now64(3)
213
+ ) ENGINE = MergeTree()
214
+ PARTITION BY toYYYYMM(timestamp)
215
+ ORDER BY (site_id, timestamp, visitor_id)
216
+ SETTINGS index_granularity = 8192
217
+ `;
218
+ var CREATE_SITES_TABLE = `
219
+ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
220
+ site_id String,
221
+ secret_key String,
222
+ name String,
223
+ domain Nullable(String),
224
+ allowed_origins Nullable(String),
225
+ created_at DateTime64(3),
226
+ updated_at DateTime64(3),
227
+ version UInt64,
228
+ is_deleted UInt8 DEFAULT 0
229
+ ) ENGINE = ReplacingMergeTree(version)
230
+ ORDER BY (site_id)
231
+ SETTINGS index_granularity = 8192
232
+ `;
233
+ var ClickHouseAdapter = class {
234
+ client;
235
+ constructor(url) {
236
+ this.client = (0, import_client.createClient)({
237
+ url,
238
+ clickhouse_settings: {
239
+ wait_end_of_query: 1
240
+ }
241
+ });
242
+ }
243
+ async init() {
244
+ await this.client.command({ query: CREATE_EVENTS_TABLE });
245
+ await this.client.command({ query: CREATE_SITES_TABLE });
246
+ }
247
+ async close() {
248
+ await this.client.close();
249
+ }
250
+ // ─── Event Insertion ──────────────────────────────────────
251
+ async insertEvents(events) {
252
+ if (events.length === 0) return;
253
+ const rows = events.map((e) => ({
254
+ site_id: e.siteId,
255
+ type: e.type,
256
+ timestamp: new Date(e.timestamp).toISOString(),
257
+ session_id: e.sessionId,
258
+ visitor_id: e.visitorId,
259
+ url: e.url ?? null,
260
+ referrer: e.referrer ?? null,
261
+ title: e.title ?? null,
262
+ event_name: e.name ?? null,
263
+ properties: e.properties ? JSON.stringify(e.properties) : null,
264
+ user_id: e.userId ?? null,
265
+ traits: e.traits ? JSON.stringify(e.traits) : null,
266
+ country: e.geo?.country ?? null,
267
+ city: e.geo?.city ?? null,
268
+ region: e.geo?.region ?? null,
269
+ device_type: e.device?.type ?? null,
270
+ browser: e.device?.browser ?? null,
271
+ os: e.device?.os ?? null,
272
+ language: e.language ?? null,
273
+ timezone: e.timezone ?? null,
274
+ screen_width: e.screen?.width ?? null,
275
+ screen_height: e.screen?.height ?? null,
276
+ utm_source: e.utm?.source ?? null,
277
+ utm_medium: e.utm?.medium ?? null,
278
+ utm_campaign: e.utm?.campaign ?? null,
279
+ utm_term: e.utm?.term ?? null,
280
+ utm_content: e.utm?.content ?? null,
281
+ ip: e.ip ?? null
282
+ }));
283
+ await this.client.insert({
284
+ table: EVENTS_TABLE,
285
+ values: rows,
286
+ format: "JSONEachRow"
287
+ });
288
+ }
289
+ // ─── Analytics Queries ──────────────────────────────────────
290
+ async query(q) {
291
+ const { dateRange, period } = resolvePeriod(q);
292
+ const siteId = q.siteId;
293
+ const limit = q.limit ?? 10;
294
+ const params = {
295
+ siteId,
296
+ from: dateRange.from,
297
+ to: dateRange.to,
298
+ limit
299
+ };
300
+ let data = [];
301
+ let total = 0;
302
+ switch (q.metric) {
303
+ case "pageviews": {
304
+ const rows = await this.queryRows(
305
+ `SELECT count() AS value FROM ${EVENTS_TABLE}
306
+ WHERE site_id = {siteId:String}
307
+ AND timestamp >= {from:String}
308
+ AND timestamp <= {to:String}
309
+ AND type = 'pageview'`,
310
+ params
311
+ );
312
+ total = Number(rows[0]?.value ?? 0);
313
+ data = [{ key: "pageviews", value: total }];
314
+ break;
315
+ }
316
+ case "visitors": {
317
+ const rows = await this.queryRows(
318
+ `SELECT uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
319
+ WHERE site_id = {siteId:String}
320
+ AND timestamp >= {from:String}
321
+ AND timestamp <= {to:String}`,
322
+ params
323
+ );
324
+ total = Number(rows[0]?.value ?? 0);
325
+ data = [{ key: "visitors", value: total }];
326
+ break;
327
+ }
328
+ case "sessions": {
329
+ const rows = await this.queryRows(
330
+ `SELECT uniq(session_id) AS value FROM ${EVENTS_TABLE}
331
+ WHERE site_id = {siteId:String}
332
+ AND timestamp >= {from:String}
333
+ AND timestamp <= {to:String}`,
334
+ params
335
+ );
336
+ total = Number(rows[0]?.value ?? 0);
337
+ data = [{ key: "sessions", value: total }];
338
+ break;
339
+ }
340
+ case "events": {
341
+ const rows = await this.queryRows(
342
+ `SELECT count() AS value FROM ${EVENTS_TABLE}
343
+ WHERE site_id = {siteId:String}
344
+ AND timestamp >= {from:String}
345
+ AND timestamp <= {to:String}
346
+ AND type = 'event'`,
347
+ params
348
+ );
349
+ total = Number(rows[0]?.value ?? 0);
350
+ data = [{ key: "events", value: total }];
351
+ break;
352
+ }
353
+ case "top_pages": {
354
+ const rows = await this.queryRows(
355
+ `SELECT url AS key, count() AS value FROM ${EVENTS_TABLE}
356
+ WHERE site_id = {siteId:String}
357
+ AND timestamp >= {from:String}
358
+ AND timestamp <= {to:String}
359
+ AND type = 'pageview'
360
+ AND url IS NOT NULL
361
+ GROUP BY url
362
+ ORDER BY value DESC
363
+ LIMIT {limit:UInt32}`,
364
+ params
365
+ );
366
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
367
+ total = data.reduce((sum, d) => sum + d.value, 0);
368
+ break;
369
+ }
370
+ case "top_referrers": {
371
+ const rows = await this.queryRows(
372
+ `SELECT referrer AS key, count() AS value FROM ${EVENTS_TABLE}
373
+ WHERE site_id = {siteId:String}
374
+ AND timestamp >= {from:String}
375
+ AND timestamp <= {to:String}
376
+ AND type = 'pageview'
377
+ AND referrer IS NOT NULL
378
+ AND referrer != ''
379
+ GROUP BY referrer
380
+ ORDER BY value DESC
381
+ LIMIT {limit:UInt32}`,
382
+ params
383
+ );
384
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
385
+ total = data.reduce((sum, d) => sum + d.value, 0);
386
+ break;
387
+ }
388
+ case "top_countries": {
389
+ const rows = await this.queryRows(
390
+ `SELECT country AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
391
+ WHERE site_id = {siteId:String}
392
+ AND timestamp >= {from:String}
393
+ AND timestamp <= {to:String}
394
+ AND country IS NOT NULL
395
+ GROUP BY country
396
+ ORDER BY value DESC
397
+ LIMIT {limit:UInt32}`,
398
+ params
399
+ );
400
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
401
+ total = data.reduce((sum, d) => sum + d.value, 0);
402
+ break;
403
+ }
404
+ case "top_cities": {
405
+ const rows = await this.queryRows(
406
+ `SELECT city AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
407
+ WHERE site_id = {siteId:String}
408
+ AND timestamp >= {from:String}
409
+ AND timestamp <= {to:String}
410
+ AND city IS NOT NULL
411
+ GROUP BY city
412
+ ORDER BY value DESC
413
+ LIMIT {limit:UInt32}`,
414
+ params
415
+ );
416
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
417
+ total = data.reduce((sum, d) => sum + d.value, 0);
418
+ break;
419
+ }
420
+ case "top_events": {
421
+ const rows = await this.queryRows(
422
+ `SELECT event_name AS key, count() AS value FROM ${EVENTS_TABLE}
423
+ WHERE site_id = {siteId:String}
424
+ AND timestamp >= {from:String}
425
+ AND timestamp <= {to:String}
426
+ AND type = 'event'
427
+ AND event_name IS NOT NULL
428
+ GROUP BY event_name
429
+ ORDER BY value DESC
430
+ LIMIT {limit:UInt32}`,
431
+ params
432
+ );
433
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
434
+ total = data.reduce((sum, d) => sum + d.value, 0);
435
+ break;
436
+ }
437
+ case "top_devices": {
438
+ const rows = await this.queryRows(
439
+ `SELECT device_type AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
440
+ WHERE site_id = {siteId:String}
441
+ AND timestamp >= {from:String}
442
+ AND timestamp <= {to:String}
443
+ AND device_type IS NOT NULL
444
+ GROUP BY device_type
445
+ ORDER BY value DESC
446
+ LIMIT {limit:UInt32}`,
447
+ params
448
+ );
449
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
450
+ total = data.reduce((sum, d) => sum + d.value, 0);
451
+ break;
452
+ }
453
+ case "top_browsers": {
454
+ const rows = await this.queryRows(
455
+ `SELECT browser AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
456
+ WHERE site_id = {siteId:String}
457
+ AND timestamp >= {from:String}
458
+ AND timestamp <= {to:String}
459
+ AND browser IS NOT NULL
460
+ GROUP BY browser
461
+ ORDER BY value DESC
462
+ LIMIT {limit:UInt32}`,
463
+ params
464
+ );
465
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
466
+ total = data.reduce((sum, d) => sum + d.value, 0);
467
+ break;
468
+ }
469
+ case "top_os": {
470
+ const rows = await this.queryRows(
471
+ `SELECT os AS key, uniq(visitor_id) AS value FROM ${EVENTS_TABLE}
472
+ WHERE site_id = {siteId:String}
473
+ AND timestamp >= {from:String}
474
+ AND timestamp <= {to:String}
475
+ AND os IS NOT NULL
476
+ GROUP BY os
477
+ ORDER BY value DESC
478
+ LIMIT {limit:UInt32}`,
479
+ params
480
+ );
481
+ data = rows.map((r) => ({ key: r.key, value: Number(r.value) }));
482
+ total = data.reduce((sum, d) => sum + d.value, 0);
483
+ break;
484
+ }
485
+ }
486
+ const result = { metric: q.metric, period, data, total };
487
+ if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
488
+ const prevRange = previousPeriodRange(dateRange);
489
+ const prevResult = await this.query({
490
+ ...q,
491
+ compare: false,
492
+ period: "custom",
493
+ dateFrom: prevRange.from,
494
+ dateTo: prevRange.to
495
+ });
496
+ result.previousTotal = prevResult.total;
497
+ if (prevResult.total > 0) {
498
+ result.changePercent = Math.round((total - prevResult.total) / prevResult.total * 1e3) / 10;
499
+ } else if (total > 0) {
500
+ result.changePercent = 100;
501
+ } else {
502
+ result.changePercent = 0;
503
+ }
504
+ }
505
+ return result;
506
+ }
507
+ // ─── Time Series ──────────────────────────────────────
508
+ async queryTimeSeries(params) {
509
+ const { dateRange, period } = resolvePeriod({
510
+ period: params.period,
511
+ dateFrom: params.dateFrom,
512
+ dateTo: params.dateTo
513
+ });
514
+ const granularity = params.granularity ?? autoGranularity(period);
515
+ const bucketFn = this.granularityToClickHouseFunc(granularity);
516
+ const dateFormat = granularityToDateFormat(granularity);
517
+ const typeFilter = params.metric === "pageviews" ? `AND type = 'pageview'` : "";
518
+ let sql;
519
+ if (params.metric === "visitors" || params.metric === "sessions") {
520
+ const field = params.metric === "visitors" ? "visitor_id" : "session_id";
521
+ sql = `
522
+ SELECT ${bucketFn} AS bucket, uniq(${field}) AS value
523
+ FROM ${EVENTS_TABLE}
524
+ WHERE site_id = {siteId:String}
525
+ AND timestamp >= {from:String}
526
+ AND timestamp <= {to:String}
527
+ ${typeFilter}
528
+ GROUP BY bucket
529
+ ORDER BY bucket ASC
530
+ `;
531
+ } else {
532
+ sql = `
533
+ SELECT ${bucketFn} AS bucket, count() AS value
534
+ FROM ${EVENTS_TABLE}
535
+ WHERE site_id = {siteId:String}
536
+ AND timestamp >= {from:String}
537
+ AND timestamp <= {to:String}
538
+ ${typeFilter}
539
+ GROUP BY bucket
540
+ ORDER BY bucket ASC
541
+ `;
542
+ }
543
+ const rows = await this.queryRows(sql, {
544
+ siteId: params.siteId,
545
+ from: dateRange.from,
546
+ to: dateRange.to
547
+ });
548
+ const mappedRows = rows.map((r) => ({
549
+ _id: this.convertClickHouseBucket(r.bucket, granularity),
550
+ value: Number(r.value)
551
+ }));
552
+ const data = fillBuckets(
553
+ new Date(dateRange.from),
554
+ new Date(dateRange.to),
555
+ granularity,
556
+ dateFormat,
557
+ mappedRows
558
+ );
559
+ return { metric: params.metric, granularity, data };
560
+ }
561
+ granularityToClickHouseFunc(g) {
562
+ switch (g) {
563
+ case "hour":
564
+ return "toStartOfHour(timestamp)";
565
+ case "day":
566
+ return "toStartOfDay(timestamp)";
567
+ case "week":
568
+ return "toStartOfWeek(timestamp, 1)";
569
+ // 1 = Monday
570
+ case "month":
571
+ return "toStartOfMonth(timestamp)";
572
+ }
573
+ }
574
+ convertClickHouseBucket(bucket, granularity) {
575
+ const date = new Date(bucket);
576
+ const y = date.getFullYear();
577
+ const m = String(date.getMonth() + 1).padStart(2, "0");
578
+ const d = String(date.getDate()).padStart(2, "0");
579
+ const h = String(date.getHours()).padStart(2, "0");
580
+ switch (granularity) {
581
+ case "hour":
582
+ return `${y}-${m}-${d}T${h}:00`;
583
+ case "day":
584
+ return `${y}-${m}-${d}`;
585
+ case "week": {
586
+ const jan4 = new Date(y, 0, 4);
587
+ const dayOfYear = Math.ceil((date.getTime() - new Date(y, 0, 1).getTime()) / 864e5) + 1;
588
+ const jan4Day = jan4.getDay() || 7;
589
+ const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
590
+ return `${y}-W${String(weekNum).padStart(2, "0")}`;
591
+ }
592
+ case "month":
593
+ return `${y}-${m}`;
594
+ }
595
+ }
596
+ // ─── Retention ──────────────────────────────────────
597
+ async queryRetention(params) {
598
+ const weeks = params.weeks ?? 8;
599
+ const now = /* @__PURE__ */ new Date();
600
+ const startDate = new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1e3);
601
+ const rows = await this.queryRows(
602
+ `SELECT
603
+ visitor_id,
604
+ min(timestamp) AS first_event,
605
+ groupUniqArray(toStartOfWeek(timestamp, 1)) AS active_weeks
606
+ FROM ${EVENTS_TABLE}
607
+ WHERE site_id = {siteId:String}
608
+ AND timestamp >= {since:String}
609
+ GROUP BY visitor_id`,
610
+ {
611
+ siteId: params.siteId,
612
+ since: startDate.toISOString()
613
+ }
614
+ );
615
+ const cohortMap = /* @__PURE__ */ new Map();
616
+ for (const v of rows) {
617
+ const firstDate = new Date(v.first_event);
618
+ const cohortWeek = getISOWeek(firstDate);
619
+ if (!cohortMap.has(cohortWeek)) {
620
+ cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
621
+ }
622
+ const cohort = cohortMap.get(cohortWeek);
623
+ cohort.visitors.add(v.visitor_id);
624
+ const eventWeeks = (Array.isArray(v.active_weeks) ? v.active_weeks : []).map((w) => {
625
+ const d = new Date(w);
626
+ return getISOWeek(d);
627
+ });
628
+ for (const w of eventWeeks) {
629
+ if (!cohort.weekSets.has(w)) {
630
+ cohort.weekSets.set(w, /* @__PURE__ */ new Set());
631
+ }
632
+ cohort.weekSets.get(w).add(v.visitor_id);
633
+ }
634
+ }
635
+ const sortedWeeks = Array.from(cohortMap.keys()).sort();
636
+ const cohorts = sortedWeeks.map((week) => {
637
+ const cohort = cohortMap.get(week);
638
+ const size = cohort.visitors.size;
639
+ const retention = [];
640
+ const weekIndex = sortedWeeks.indexOf(week);
641
+ for (let i = 0; i < weeks && weekIndex + i < sortedWeeks.length; i++) {
642
+ const targetWeek = sortedWeeks[weekIndex + i];
643
+ const returnedCount = cohort.weekSets.get(targetWeek)?.size ?? 0;
644
+ retention.push(size > 0 ? Math.round(returnedCount / size * 1e3) / 10 : 0);
645
+ }
646
+ return { week, size, retention };
647
+ });
648
+ return { cohorts };
649
+ }
650
+ // ─── Event Listing ──────────────────────────────────────
651
+ async listEvents(params) {
652
+ const limit = Math.min(params.limit ?? 50, 200);
653
+ const offset = params.offset ?? 0;
654
+ const conditions = [`site_id = {siteId:String}`];
655
+ const queryParams = { siteId: params.siteId, limit, offset };
656
+ if (params.type) {
657
+ conditions.push(`type = {type:String}`);
658
+ queryParams.type = params.type;
659
+ }
660
+ if (params.eventName) {
661
+ conditions.push(`event_name = {eventName:String}`);
662
+ queryParams.eventName = params.eventName;
663
+ }
664
+ if (params.visitorId) {
665
+ conditions.push(`visitor_id = {visitorId:String}`);
666
+ queryParams.visitorId = params.visitorId;
667
+ }
668
+ if (params.userId) {
669
+ conditions.push(`user_id = {userId:String}`);
670
+ queryParams.userId = params.userId;
671
+ }
672
+ if (params.period || params.dateFrom) {
673
+ const { dateRange } = resolvePeriod({
674
+ period: params.period,
675
+ dateFrom: params.dateFrom,
676
+ dateTo: params.dateTo
677
+ });
678
+ conditions.push(`timestamp >= {from:String} AND timestamp <= {to:String}`);
679
+ queryParams.from = dateRange.from;
680
+ queryParams.to = dateRange.to;
681
+ }
682
+ const where = conditions.join(" AND ");
683
+ const [events, countRows] = await Promise.all([
684
+ this.queryRows(
685
+ `SELECT event_id, type, timestamp, session_id, visitor_id, url, referrer, title,
686
+ event_name, properties, user_id, traits, country, city, region,
687
+ device_type, browser, os, language,
688
+ utm_source, utm_medium, utm_campaign, utm_term, utm_content
689
+ FROM ${EVENTS_TABLE}
690
+ WHERE ${where}
691
+ ORDER BY timestamp DESC
692
+ LIMIT {limit:UInt32}
693
+ OFFSET {offset:UInt32}`,
694
+ queryParams
695
+ ),
696
+ this.queryRows(
697
+ `SELECT count() AS total FROM ${EVENTS_TABLE} WHERE ${where}`,
698
+ queryParams
699
+ )
700
+ ]);
701
+ return {
702
+ events: events.map((e) => this.toEventListItem(e)),
703
+ total: Number(countRows[0]?.total ?? 0),
704
+ limit,
705
+ offset
706
+ };
707
+ }
708
+ // ─── User Listing ──────────────────────────────────────
709
+ async listUsers(params) {
710
+ const limit = Math.min(params.limit ?? 50, 200);
711
+ const offset = params.offset ?? 0;
712
+ const conditions = [`site_id = {siteId:String}`];
713
+ const queryParams = { siteId: params.siteId, limit, offset };
714
+ if (params.search) {
715
+ conditions.push(`(visitor_id ILIKE {search:String} OR user_id ILIKE {search:String})`);
716
+ queryParams.search = `%${params.search}%`;
717
+ }
718
+ const where = conditions.join(" AND ");
719
+ const [userRows, countRows] = await Promise.all([
720
+ this.queryRows(
721
+ `SELECT
722
+ visitor_id,
723
+ anyLast(user_id) AS userId,
724
+ anyLast(traits) AS traits,
725
+ min(timestamp) AS firstSeen,
726
+ max(timestamp) AS lastSeen,
727
+ count() AS totalEvents,
728
+ countIf(type = 'pageview') AS totalPageviews,
729
+ uniq(session_id) AS totalSessions,
730
+ anyLast(url) AS lastUrl,
731
+ anyLast(device_type) AS device_type,
732
+ anyLast(browser) AS browser,
733
+ anyLast(os) AS os,
734
+ anyLast(country) AS country,
735
+ anyLast(city) AS city,
736
+ anyLast(region) AS region,
737
+ anyLast(language) AS language
738
+ FROM ${EVENTS_TABLE}
739
+ WHERE ${where}
740
+ GROUP BY visitor_id
741
+ ORDER BY lastSeen DESC
742
+ LIMIT {limit:UInt32}
743
+ OFFSET {offset:UInt32}`,
744
+ queryParams
745
+ ),
746
+ this.queryRows(
747
+ `SELECT count() AS total FROM (
748
+ SELECT visitor_id FROM ${EVENTS_TABLE}
749
+ WHERE ${where}
750
+ GROUP BY visitor_id
751
+ )`,
752
+ queryParams
753
+ )
754
+ ]);
755
+ const users = userRows.map((u) => ({
756
+ visitorId: String(u.visitor_id),
757
+ userId: u.userId ? String(u.userId) : void 0,
758
+ traits: this.parseJSON(u.traits),
759
+ firstSeen: new Date(String(u.firstSeen)).toISOString(),
760
+ lastSeen: new Date(String(u.lastSeen)).toISOString(),
761
+ totalEvents: Number(u.totalEvents),
762
+ totalPageviews: Number(u.totalPageviews),
763
+ totalSessions: Number(u.totalSessions),
764
+ lastUrl: u.lastUrl ? String(u.lastUrl) : void 0,
765
+ device: u.device_type ? { type: String(u.device_type), browser: String(u.browser ?? ""), os: String(u.os ?? "") } : void 0,
766
+ 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,
767
+ language: u.language ? String(u.language) : void 0
768
+ }));
769
+ return {
770
+ users,
771
+ total: Number(countRows[0]?.total ?? 0),
772
+ limit,
773
+ offset
774
+ };
775
+ }
776
+ async getUserDetail(siteId, visitorId) {
777
+ const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
778
+ const user = result.users.find((u) => u.visitorId === visitorId);
779
+ return user ?? null;
780
+ }
781
+ async getUserEvents(siteId, visitorId, params) {
782
+ return this.listEvents({ ...params, siteId, visitorId });
783
+ }
784
+ // ─── Site Management ──────────────────────────────────────
785
+ async createSite(data) {
786
+ const now = (/* @__PURE__ */ new Date()).toISOString();
787
+ const site = {
788
+ siteId: generateSiteId(),
789
+ secretKey: generateSecretKey(),
790
+ name: data.name,
791
+ domain: data.domain,
792
+ allowedOrigins: data.allowedOrigins,
793
+ createdAt: now,
794
+ updatedAt: now
795
+ };
796
+ await this.client.insert({
797
+ table: SITES_TABLE,
798
+ values: [{
799
+ site_id: site.siteId,
800
+ secret_key: site.secretKey,
801
+ name: site.name,
802
+ domain: site.domain ?? null,
803
+ allowed_origins: site.allowedOrigins ? JSON.stringify(site.allowedOrigins) : null,
804
+ created_at: now,
805
+ updated_at: now,
806
+ version: 1,
807
+ is_deleted: 0
808
+ }],
809
+ format: "JSONEachRow"
810
+ });
811
+ return site;
812
+ }
813
+ async getSite(siteId) {
814
+ const rows = await this.queryRows(
815
+ `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
816
+ FROM ${SITES_TABLE} FINAL
817
+ WHERE site_id = {siteId:String} AND is_deleted = 0`,
818
+ { siteId }
819
+ );
820
+ return rows.length > 0 ? this.toSite(rows[0]) : null;
821
+ }
822
+ async getSiteBySecret(secretKey) {
823
+ const rows = await this.queryRows(
824
+ `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
825
+ FROM ${SITES_TABLE} FINAL
826
+ WHERE secret_key = {secretKey:String} AND is_deleted = 0`,
827
+ { secretKey }
828
+ );
829
+ return rows.length > 0 ? this.toSite(rows[0]) : null;
830
+ }
831
+ async listSites() {
832
+ const rows = await this.queryRows(
833
+ `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at
834
+ FROM ${SITES_TABLE} FINAL
835
+ WHERE is_deleted = 0
836
+ ORDER BY created_at DESC`,
837
+ {}
838
+ );
839
+ return rows.map((r) => this.toSite(r));
840
+ }
841
+ async updateSite(siteId, data) {
842
+ const currentRows = await this.queryRows(
843
+ `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, updated_at, version
844
+ FROM ${SITES_TABLE} FINAL
845
+ WHERE site_id = {siteId:String} AND is_deleted = 0`,
846
+ { siteId }
847
+ );
848
+ if (currentRows.length === 0) return null;
849
+ const current = currentRows[0];
850
+ const now = (/* @__PURE__ */ new Date()).toISOString();
851
+ const newVersion = Number(current.version) + 1;
852
+ const newName = data.name !== void 0 ? data.name : String(current.name);
853
+ const newDomain = data.domain !== void 0 ? data.domain || null : current.domain ? String(current.domain) : null;
854
+ const newOrigins = data.allowedOrigins !== void 0 ? data.allowedOrigins.length > 0 ? JSON.stringify(data.allowedOrigins) : null : current.allowed_origins ? String(current.allowed_origins) : null;
855
+ await this.client.insert({
856
+ table: SITES_TABLE,
857
+ values: [{
858
+ site_id: String(current.site_id),
859
+ secret_key: String(current.secret_key),
860
+ name: newName,
861
+ domain: newDomain,
862
+ allowed_origins: newOrigins,
863
+ created_at: String(current.created_at),
864
+ updated_at: now,
865
+ version: newVersion,
866
+ is_deleted: 0
867
+ }],
868
+ format: "JSONEachRow"
869
+ });
870
+ return {
871
+ siteId: String(current.site_id),
872
+ secretKey: String(current.secret_key),
873
+ name: newName,
874
+ domain: newDomain ?? void 0,
875
+ allowedOrigins: newOrigins ? JSON.parse(newOrigins) : void 0,
876
+ createdAt: String(current.created_at),
877
+ updatedAt: now
878
+ };
879
+ }
880
+ async deleteSite(siteId) {
881
+ const currentRows = await this.queryRows(
882
+ `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
883
+ FROM ${SITES_TABLE} FINAL
884
+ WHERE site_id = {siteId:String} AND is_deleted = 0`,
885
+ { siteId }
886
+ );
887
+ if (currentRows.length === 0) return false;
888
+ const current = currentRows[0];
889
+ const now = (/* @__PURE__ */ new Date()).toISOString();
890
+ await this.client.insert({
891
+ table: SITES_TABLE,
892
+ values: [{
893
+ site_id: String(current.site_id),
894
+ secret_key: String(current.secret_key),
895
+ name: String(current.name),
896
+ domain: current.domain ? String(current.domain) : null,
897
+ allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
898
+ created_at: String(current.created_at),
899
+ updated_at: now,
900
+ version: Number(current.version) + 1,
901
+ is_deleted: 1
902
+ }],
903
+ format: "JSONEachRow"
904
+ });
905
+ return true;
906
+ }
907
+ async regenerateSecret(siteId) {
908
+ const currentRows = await this.queryRows(
909
+ `SELECT site_id, secret_key, name, domain, allowed_origins, created_at, version
910
+ FROM ${SITES_TABLE} FINAL
911
+ WHERE site_id = {siteId:String} AND is_deleted = 0`,
912
+ { siteId }
913
+ );
914
+ if (currentRows.length === 0) return null;
915
+ const current = currentRows[0];
916
+ const now = (/* @__PURE__ */ new Date()).toISOString();
917
+ const newSecret = generateSecretKey();
918
+ await this.client.insert({
919
+ table: SITES_TABLE,
920
+ values: [{
921
+ site_id: String(current.site_id),
922
+ secret_key: newSecret,
923
+ name: String(current.name),
924
+ domain: current.domain ? String(current.domain) : null,
925
+ allowed_origins: current.allowed_origins ? String(current.allowed_origins) : null,
926
+ created_at: String(current.created_at),
927
+ updated_at: now,
928
+ version: Number(current.version) + 1,
929
+ is_deleted: 0
930
+ }],
931
+ format: "JSONEachRow"
932
+ });
933
+ return {
934
+ siteId: String(current.site_id),
935
+ secretKey: newSecret,
936
+ name: String(current.name),
937
+ domain: current.domain ? String(current.domain) : void 0,
938
+ allowedOrigins: current.allowed_origins ? JSON.parse(String(current.allowed_origins)) : void 0,
939
+ createdAt: String(current.created_at),
940
+ updatedAt: now
941
+ };
942
+ }
943
+ // ─── Helpers ─────────────────────────────────────────────
944
+ async queryRows(query, query_params) {
945
+ const result = await this.client.query({
946
+ query,
947
+ query_params,
948
+ format: "JSONEachRow"
949
+ });
950
+ return result.json();
951
+ }
952
+ toSite(row) {
953
+ return {
954
+ siteId: String(row.site_id),
955
+ secretKey: String(row.secret_key),
956
+ name: String(row.name),
957
+ domain: row.domain ? String(row.domain) : void 0,
958
+ allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
959
+ createdAt: new Date(String(row.created_at)).toISOString(),
960
+ updatedAt: new Date(String(row.updated_at)).toISOString()
961
+ };
962
+ }
963
+ toEventListItem(row) {
964
+ return {
965
+ id: String(row.event_id ?? ""),
966
+ type: String(row.type),
967
+ timestamp: new Date(String(row.timestamp)).toISOString(),
968
+ visitorId: String(row.visitor_id),
969
+ sessionId: String(row.session_id),
970
+ url: row.url ? String(row.url) : void 0,
971
+ referrer: row.referrer ? String(row.referrer) : void 0,
972
+ title: row.title ? String(row.title) : void 0,
973
+ name: row.event_name ? String(row.event_name) : void 0,
974
+ properties: this.parseJSON(row.properties),
975
+ userId: row.user_id ? String(row.user_id) : void 0,
976
+ traits: this.parseJSON(row.traits),
977
+ geo: row.country ? {
978
+ country: String(row.country),
979
+ city: row.city ? String(row.city) : void 0,
980
+ region: row.region ? String(row.region) : void 0
981
+ } : void 0,
982
+ device: row.device_type ? {
983
+ type: String(row.device_type),
984
+ browser: String(row.browser ?? ""),
985
+ os: String(row.os ?? "")
986
+ } : void 0,
987
+ language: row.language ? String(row.language) : void 0,
988
+ utm: row.utm_source ? {
989
+ source: row.utm_source ? String(row.utm_source) : void 0,
990
+ medium: row.utm_medium ? String(row.utm_medium) : void 0,
991
+ campaign: row.utm_campaign ? String(row.utm_campaign) : void 0,
992
+ term: row.utm_term ? String(row.utm_term) : void 0,
993
+ content: row.utm_content ? String(row.utm_content) : void 0
994
+ } : void 0
995
+ };
996
+ }
997
+ parseJSON(str) {
998
+ if (!str) return void 0;
999
+ try {
1000
+ return JSON.parse(str);
1001
+ } catch {
1002
+ return void 0;
1003
+ }
1004
+ }
1005
+ };
1006
+
1007
+ // src/adapters/mongodb.ts
1008
+ var import_mongodb = require("mongodb");
1009
+ var EVENTS_COLLECTION = "litemetrics_events";
1010
+ var SITES_COLLECTION = "litemetrics_sites";
1011
+ var MongoDBAdapter = class {
1012
+ client;
1013
+ db;
1014
+ collection;
1015
+ sites;
1016
+ constructor(url) {
1017
+ this.client = new import_mongodb.MongoClient(url);
1018
+ }
1019
+ async init() {
1020
+ await this.client.connect();
1021
+ this.db = this.client.db();
1022
+ this.collection = this.db.collection(EVENTS_COLLECTION);
1023
+ this.sites = this.db.collection(SITES_COLLECTION);
1024
+ await Promise.all([
1025
+ this.collection.createIndex({ site_id: 1, timestamp: -1 }),
1026
+ this.collection.createIndex({ site_id: 1, type: 1 }),
1027
+ this.collection.createIndex({ site_id: 1, visitor_id: 1 }),
1028
+ this.collection.createIndex({ site_id: 1, session_id: 1 }),
1029
+ this.sites.createIndex({ site_id: 1 }, { unique: true }),
1030
+ this.sites.createIndex({ secret_key: 1 })
1031
+ ]);
1032
+ }
1033
+ async insertEvents(events) {
1034
+ if (events.length === 0) return;
1035
+ const docs = events.map((e) => ({
1036
+ site_id: e.siteId,
1037
+ type: e.type,
1038
+ timestamp: new Date(e.timestamp),
1039
+ session_id: e.sessionId,
1040
+ visitor_id: e.visitorId,
1041
+ url: e.url ?? null,
1042
+ referrer: e.referrer ?? null,
1043
+ title: e.title ?? null,
1044
+ event_name: e.name ?? null,
1045
+ properties: e.properties ?? null,
1046
+ user_id: e.userId ?? null,
1047
+ traits: e.traits ?? null,
1048
+ country: e.geo?.country ?? null,
1049
+ city: e.geo?.city ?? null,
1050
+ region: e.geo?.region ?? null,
1051
+ device_type: e.device?.type ?? null,
1052
+ browser: e.device?.browser ?? null,
1053
+ os: e.device?.os ?? null,
1054
+ language: e.language ?? null,
1055
+ timezone: e.timezone ?? null,
1056
+ screen_width: e.screen?.width ?? null,
1057
+ screen_height: e.screen?.height ?? null,
1058
+ utm_source: e.utm?.source ?? null,
1059
+ utm_medium: e.utm?.medium ?? null,
1060
+ utm_campaign: e.utm?.campaign ?? null,
1061
+ utm_term: e.utm?.term ?? null,
1062
+ utm_content: e.utm?.content ?? null,
1063
+ ip: e.ip ?? null,
1064
+ created_at: /* @__PURE__ */ new Date()
1065
+ }));
1066
+ await this.collection.insertMany(docs);
1067
+ }
1068
+ async query(q) {
1069
+ const { dateRange, period } = resolvePeriod(q);
1070
+ const siteId = q.siteId;
1071
+ const limit = q.limit ?? 10;
1072
+ const baseMatch = {
1073
+ site_id: siteId,
1074
+ timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1075
+ };
1076
+ let data = [];
1077
+ let total = 0;
1078
+ switch (q.metric) {
1079
+ case "pageviews": {
1080
+ const [result2] = await this.collection.aggregate([
1081
+ { $match: { ...baseMatch, type: "pageview" } },
1082
+ { $count: "count" }
1083
+ ]).toArray();
1084
+ total = result2?.count ?? 0;
1085
+ data = [{ key: "pageviews", value: total }];
1086
+ break;
1087
+ }
1088
+ case "visitors": {
1089
+ const [result2] = await this.collection.aggregate([
1090
+ { $match: baseMatch },
1091
+ { $group: { _id: "$visitor_id" } },
1092
+ { $count: "count" }
1093
+ ]).toArray();
1094
+ total = result2?.count ?? 0;
1095
+ data = [{ key: "visitors", value: total }];
1096
+ break;
1097
+ }
1098
+ case "sessions": {
1099
+ const [result2] = await this.collection.aggregate([
1100
+ { $match: baseMatch },
1101
+ { $group: { _id: "$session_id" } },
1102
+ { $count: "count" }
1103
+ ]).toArray();
1104
+ total = result2?.count ?? 0;
1105
+ data = [{ key: "sessions", value: total }];
1106
+ break;
1107
+ }
1108
+ case "events": {
1109
+ const [result2] = await this.collection.aggregate([
1110
+ { $match: { ...baseMatch, type: "event" } },
1111
+ { $count: "count" }
1112
+ ]).toArray();
1113
+ total = result2?.count ?? 0;
1114
+ data = [{ key: "events", value: total }];
1115
+ break;
1116
+ }
1117
+ case "top_pages": {
1118
+ const rows = await this.collection.aggregate([
1119
+ { $match: { ...baseMatch, type: "pageview", url: { $ne: null } } },
1120
+ { $group: { _id: "$url", value: { $sum: 1 } } },
1121
+ { $sort: { value: -1 } },
1122
+ { $limit: limit }
1123
+ ]).toArray();
1124
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1125
+ total = data.reduce((sum, d) => sum + d.value, 0);
1126
+ break;
1127
+ }
1128
+ case "top_referrers": {
1129
+ const rows = await this.collection.aggregate([
1130
+ { $match: { ...baseMatch, type: "pageview", referrer: { $nin: [null, ""] } } },
1131
+ { $group: { _id: "$referrer", value: { $sum: 1 } } },
1132
+ { $sort: { value: -1 } },
1133
+ { $limit: limit }
1134
+ ]).toArray();
1135
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1136
+ total = data.reduce((sum, d) => sum + d.value, 0);
1137
+ break;
1138
+ }
1139
+ case "top_countries": {
1140
+ const rows = await this.collection.aggregate([
1141
+ { $match: { ...baseMatch, country: { $ne: null } } },
1142
+ { $group: { _id: "$country", value: { $addToSet: "$visitor_id" } } },
1143
+ { $project: { _id: 1, value: { $size: "$value" } } },
1144
+ { $sort: { value: -1 } },
1145
+ { $limit: limit }
1146
+ ]).toArray();
1147
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1148
+ total = data.reduce((sum, d) => sum + d.value, 0);
1149
+ break;
1150
+ }
1151
+ case "top_cities": {
1152
+ const rows = await this.collection.aggregate([
1153
+ { $match: { ...baseMatch, city: { $ne: null } } },
1154
+ { $group: { _id: "$city", value: { $addToSet: "$visitor_id" } } },
1155
+ { $project: { _id: 1, value: { $size: "$value" } } },
1156
+ { $sort: { value: -1 } },
1157
+ { $limit: limit }
1158
+ ]).toArray();
1159
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1160
+ total = data.reduce((sum, d) => sum + d.value, 0);
1161
+ break;
1162
+ }
1163
+ case "top_events": {
1164
+ const rows = await this.collection.aggregate([
1165
+ { $match: { ...baseMatch, type: "event", event_name: { $ne: null } } },
1166
+ { $group: { _id: "$event_name", value: { $sum: 1 } } },
1167
+ { $sort: { value: -1 } },
1168
+ { $limit: limit }
1169
+ ]).toArray();
1170
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1171
+ total = data.reduce((sum, d) => sum + d.value, 0);
1172
+ break;
1173
+ }
1174
+ case "top_devices": {
1175
+ const rows = await this.collection.aggregate([
1176
+ { $match: { ...baseMatch, device_type: { $ne: null } } },
1177
+ { $group: { _id: "$device_type", value: { $addToSet: "$visitor_id" } } },
1178
+ { $project: { _id: 1, value: { $size: "$value" } } },
1179
+ { $sort: { value: -1 } },
1180
+ { $limit: limit }
1181
+ ]).toArray();
1182
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1183
+ total = data.reduce((sum, d) => sum + d.value, 0);
1184
+ break;
1185
+ }
1186
+ case "top_browsers": {
1187
+ const rows = await this.collection.aggregate([
1188
+ { $match: { ...baseMatch, browser: { $ne: null } } },
1189
+ { $group: { _id: "$browser", value: { $addToSet: "$visitor_id" } } },
1190
+ { $project: { _id: 1, value: { $size: "$value" } } },
1191
+ { $sort: { value: -1 } },
1192
+ { $limit: limit }
1193
+ ]).toArray();
1194
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1195
+ total = data.reduce((sum, d) => sum + d.value, 0);
1196
+ break;
1197
+ }
1198
+ case "top_os": {
1199
+ const rows = await this.collection.aggregate([
1200
+ { $match: { ...baseMatch, os: { $ne: null } } },
1201
+ { $group: { _id: "$os", value: { $addToSet: "$visitor_id" } } },
1202
+ { $project: { _id: 1, value: { $size: "$value" } } },
1203
+ { $sort: { value: -1 } },
1204
+ { $limit: limit }
1205
+ ]).toArray();
1206
+ data = rows.map((r) => ({ key: r._id, value: r.value }));
1207
+ total = data.reduce((sum, d) => sum + d.value, 0);
1208
+ break;
1209
+ }
1210
+ }
1211
+ const result = { metric: q.metric, period, data, total };
1212
+ if (q.compare && ["pageviews", "visitors", "sessions", "events"].includes(q.metric)) {
1213
+ const prevRange = previousPeriodRange(dateRange);
1214
+ const prevResult = await this.query({
1215
+ ...q,
1216
+ compare: false,
1217
+ period: "custom",
1218
+ dateFrom: prevRange.from,
1219
+ dateTo: prevRange.to
1220
+ });
1221
+ result.previousTotal = prevResult.total;
1222
+ if (prevResult.total > 0) {
1223
+ result.changePercent = Math.round((total - prevResult.total) / prevResult.total * 1e3) / 10;
1224
+ } else if (total > 0) {
1225
+ result.changePercent = 100;
1226
+ } else {
1227
+ result.changePercent = 0;
1228
+ }
1229
+ }
1230
+ return result;
1231
+ }
1232
+ // ─── Time Series ──────────────────────────────────────
1233
+ async queryTimeSeries(params) {
1234
+ const { dateRange, period } = resolvePeriod({
1235
+ period: params.period,
1236
+ dateFrom: params.dateFrom,
1237
+ dateTo: params.dateTo
1238
+ });
1239
+ const granularity = params.granularity ?? autoGranularity(period);
1240
+ const baseMatch = {
1241
+ site_id: params.siteId,
1242
+ timestamp: { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) }
1243
+ };
1244
+ if (params.metric === "pageviews") {
1245
+ baseMatch.type = "pageview";
1246
+ }
1247
+ const dateFormat = granularityToDateFormat(granularity);
1248
+ let pipeline;
1249
+ if (params.metric === "visitors" || params.metric === "sessions") {
1250
+ const groupField = params.metric === "visitors" ? "$visitor_id" : "$session_id";
1251
+ pipeline = [
1252
+ { $match: baseMatch },
1253
+ {
1254
+ $group: {
1255
+ _id: {
1256
+ bucket: { $dateToString: { format: dateFormat, date: "$timestamp" } },
1257
+ entity: groupField
1258
+ }
1259
+ }
1260
+ },
1261
+ {
1262
+ $group: {
1263
+ _id: "$_id.bucket",
1264
+ value: { $sum: 1 }
1265
+ }
1266
+ },
1267
+ { $sort: { _id: 1 } }
1268
+ ];
1269
+ } else {
1270
+ pipeline = [
1271
+ { $match: baseMatch },
1272
+ {
1273
+ $group: {
1274
+ _id: { $dateToString: { format: dateFormat, date: "$timestamp" } },
1275
+ value: { $sum: 1 }
1276
+ }
1277
+ },
1278
+ { $sort: { _id: 1 } }
1279
+ ];
1280
+ }
1281
+ const rows = await this.collection.aggregate(pipeline).toArray();
1282
+ const data = fillBuckets(
1283
+ new Date(dateRange.from),
1284
+ new Date(dateRange.to),
1285
+ granularity,
1286
+ dateFormat,
1287
+ rows
1288
+ );
1289
+ return { metric: params.metric, granularity, data };
1290
+ }
1291
+ // ─── Retention ──────────────────────────────────────
1292
+ async queryRetention(params) {
1293
+ const weeks = params.weeks ?? 8;
1294
+ const now = /* @__PURE__ */ new Date();
1295
+ const startDate = new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1e3);
1296
+ const pipeline = [
1297
+ {
1298
+ $match: {
1299
+ site_id: params.siteId,
1300
+ timestamp: { $gte: startDate }
1301
+ }
1302
+ },
1303
+ {
1304
+ $group: {
1305
+ _id: "$visitor_id",
1306
+ firstEvent: { $min: "$timestamp" },
1307
+ eventWeeks: {
1308
+ $addToSet: {
1309
+ $dateToString: { format: "%G-W%V", date: "$timestamp" }
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+ ];
1315
+ const visitors = await this.collection.aggregate(pipeline).toArray();
1316
+ const cohortMap = /* @__PURE__ */ new Map();
1317
+ for (const v of visitors) {
1318
+ const cohortWeek = getISOWeek(v.firstEvent);
1319
+ if (!cohortMap.has(cohortWeek)) {
1320
+ cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
1321
+ }
1322
+ const cohort = cohortMap.get(cohortWeek);
1323
+ cohort.visitors.add(v._id);
1324
+ for (const w of v.eventWeeks) {
1325
+ if (!cohort.weekSets.has(w)) {
1326
+ cohort.weekSets.set(w, /* @__PURE__ */ new Set());
1327
+ }
1328
+ cohort.weekSets.get(w).add(v._id);
1329
+ }
1330
+ }
1331
+ const sortedWeeks = Array.from(cohortMap.keys()).sort();
1332
+ const cohorts = sortedWeeks.map((week) => {
1333
+ const cohort = cohortMap.get(week);
1334
+ const size = cohort.visitors.size;
1335
+ const retention = [];
1336
+ const weekIndex = sortedWeeks.indexOf(week);
1337
+ for (let i = 0; i < weeks && weekIndex + i < sortedWeeks.length; i++) {
1338
+ const targetWeek = sortedWeeks[weekIndex + i];
1339
+ const returnedCount = cohort.weekSets.get(targetWeek)?.size ?? 0;
1340
+ retention.push(size > 0 ? Math.round(returnedCount / size * 1e3) / 10 : 0);
1341
+ }
1342
+ return { week, size, retention };
1343
+ });
1344
+ return { cohorts };
1345
+ }
1346
+ // ─── Event Listing ──────────────────────────────────────
1347
+ async listEvents(params) {
1348
+ const limit = Math.min(params.limit ?? 50, 200);
1349
+ const offset = params.offset ?? 0;
1350
+ const match = { site_id: params.siteId };
1351
+ if (params.type) match.type = params.type;
1352
+ if (params.eventName) match.event_name = params.eventName;
1353
+ if (params.visitorId) match.visitor_id = params.visitorId;
1354
+ if (params.userId) match.user_id = params.userId;
1355
+ if (params.period || params.dateFrom) {
1356
+ const { dateRange } = resolvePeriod({
1357
+ period: params.period,
1358
+ dateFrom: params.dateFrom,
1359
+ dateTo: params.dateTo
1360
+ });
1361
+ match.timestamp = { $gte: new Date(dateRange.from), $lte: new Date(dateRange.to) };
1362
+ }
1363
+ const [events, countResult] = await Promise.all([
1364
+ this.collection.find(match).sort({ timestamp: -1 }).skip(offset).limit(limit).toArray(),
1365
+ this.collection.countDocuments(match)
1366
+ ]);
1367
+ return {
1368
+ events: events.map((e) => this.toEventListItem(e)),
1369
+ total: countResult,
1370
+ limit,
1371
+ offset
1372
+ };
1373
+ }
1374
+ // ─── User Listing ──────────────────────────────────────
1375
+ async listUsers(params) {
1376
+ const limit = Math.min(params.limit ?? 50, 200);
1377
+ const offset = params.offset ?? 0;
1378
+ const match = { site_id: params.siteId };
1379
+ if (params.search) {
1380
+ match.$or = [
1381
+ { visitor_id: { $regex: params.search, $options: "i" } },
1382
+ { user_id: { $regex: params.search, $options: "i" } }
1383
+ ];
1384
+ }
1385
+ const pipeline = [
1386
+ { $match: match },
1387
+ {
1388
+ $group: {
1389
+ _id: "$visitor_id",
1390
+ userId: { $last: "$user_id" },
1391
+ traits: { $last: "$traits" },
1392
+ firstSeen: { $min: "$timestamp" },
1393
+ lastSeen: { $max: "$timestamp" },
1394
+ totalEvents: { $sum: 1 },
1395
+ totalPageviews: { $sum: { $cond: [{ $eq: ["$type", "pageview"] }, 1, 0] } },
1396
+ sessions: { $addToSet: "$session_id" },
1397
+ lastUrl: { $last: "$url" },
1398
+ device_type: { $last: "$device_type" },
1399
+ browser: { $last: "$browser" },
1400
+ os: { $last: "$os" },
1401
+ country: { $last: "$country" },
1402
+ city: { $last: "$city" },
1403
+ region: { $last: "$region" },
1404
+ language: { $last: "$language" }
1405
+ }
1406
+ },
1407
+ { $sort: { lastSeen: -1 } },
1408
+ {
1409
+ $facet: {
1410
+ data: [{ $skip: offset }, { $limit: limit }],
1411
+ count: [{ $count: "total" }]
1412
+ }
1413
+ }
1414
+ ];
1415
+ const [result] = await this.collection.aggregate(pipeline).toArray();
1416
+ const users = (result?.data ?? []).map((u) => ({
1417
+ visitorId: u._id,
1418
+ userId: u.userId ?? void 0,
1419
+ traits: u.traits ?? void 0,
1420
+ firstSeen: u.firstSeen.toISOString(),
1421
+ lastSeen: u.lastSeen.toISOString(),
1422
+ totalEvents: u.totalEvents,
1423
+ totalPageviews: u.totalPageviews,
1424
+ totalSessions: u.sessions.length,
1425
+ lastUrl: u.lastUrl ?? void 0,
1426
+ device: u.device_type ? { type: u.device_type, browser: u.browser ?? "", os: u.os ?? "" } : void 0,
1427
+ geo: u.country ? { country: u.country, city: u.city ?? void 0, region: u.region ?? void 0 } : void 0,
1428
+ language: u.language ?? void 0
1429
+ }));
1430
+ return {
1431
+ users,
1432
+ total: result?.count?.[0]?.total ?? 0,
1433
+ limit,
1434
+ offset
1435
+ };
1436
+ }
1437
+ async getUserDetail(siteId, visitorId) {
1438
+ const result = await this.listUsers({ siteId, search: visitorId, limit: 1 });
1439
+ const user = result.users.find((u) => u.visitorId === visitorId);
1440
+ return user ?? null;
1441
+ }
1442
+ async getUserEvents(siteId, visitorId, params) {
1443
+ return this.listEvents({ ...params, siteId, visitorId });
1444
+ }
1445
+ toEventListItem(doc) {
1446
+ return {
1447
+ id: doc._id?.toString?.() ?? "",
1448
+ type: doc.type,
1449
+ timestamp: doc.timestamp.toISOString(),
1450
+ visitorId: doc.visitor_id,
1451
+ sessionId: doc.session_id,
1452
+ url: doc.url ?? void 0,
1453
+ referrer: doc.referrer ?? void 0,
1454
+ title: doc.title ?? void 0,
1455
+ name: doc.event_name ?? void 0,
1456
+ properties: doc.properties ?? void 0,
1457
+ userId: doc.user_id ?? void 0,
1458
+ traits: doc.traits ?? void 0,
1459
+ geo: doc.country ? { country: doc.country, city: doc.city ?? void 0, region: doc.region ?? void 0 } : void 0,
1460
+ device: doc.device_type ? { type: doc.device_type, browser: doc.browser ?? "", os: doc.os ?? "" } : void 0,
1461
+ language: doc.language ?? void 0,
1462
+ utm: doc.utm_source ? {
1463
+ source: doc.utm_source ?? void 0,
1464
+ medium: doc.utm_medium ?? void 0,
1465
+ campaign: doc.utm_campaign ?? void 0,
1466
+ term: doc.utm_term ?? void 0,
1467
+ content: doc.utm_content ?? void 0
1468
+ } : void 0
1469
+ };
1470
+ }
1471
+ // ─── Site Management ──────────────────────────────────────
1472
+ async createSite(data) {
1473
+ const now = /* @__PURE__ */ new Date();
1474
+ const doc = {
1475
+ site_id: generateSiteId(),
1476
+ secret_key: generateSecretKey(),
1477
+ name: data.name,
1478
+ domain: data.domain ?? null,
1479
+ allowed_origins: data.allowedOrigins ?? null,
1480
+ created_at: now,
1481
+ updated_at: now
1482
+ };
1483
+ await this.sites.insertOne(doc);
1484
+ return this.toSite(doc);
1485
+ }
1486
+ async getSite(siteId) {
1487
+ const doc = await this.sites.findOne({ site_id: siteId });
1488
+ return doc ? this.toSite(doc) : null;
1489
+ }
1490
+ async getSiteBySecret(secretKey) {
1491
+ const doc = await this.sites.findOne({ secret_key: secretKey });
1492
+ return doc ? this.toSite(doc) : null;
1493
+ }
1494
+ async listSites() {
1495
+ const docs = await this.sites.find({}).sort({ created_at: -1 }).toArray();
1496
+ return docs.map((d) => this.toSite(d));
1497
+ }
1498
+ async updateSite(siteId, data) {
1499
+ const updates = { updated_at: /* @__PURE__ */ new Date() };
1500
+ if (data.name !== void 0) updates.name = data.name;
1501
+ if (data.domain !== void 0) updates.domain = data.domain || null;
1502
+ if (data.allowedOrigins !== void 0) updates.allowed_origins = data.allowedOrigins.length > 0 ? data.allowedOrigins : null;
1503
+ const result = await this.sites.findOneAndUpdate(
1504
+ { site_id: siteId },
1505
+ { $set: updates },
1506
+ { returnDocument: "after" }
1507
+ );
1508
+ return result ? this.toSite(result) : null;
1509
+ }
1510
+ async deleteSite(siteId) {
1511
+ const result = await this.sites.deleteOne({ site_id: siteId });
1512
+ return result.deletedCount > 0;
1513
+ }
1514
+ async regenerateSecret(siteId) {
1515
+ const result = await this.sites.findOneAndUpdate(
1516
+ { site_id: siteId },
1517
+ { $set: { secret_key: generateSecretKey(), updated_at: /* @__PURE__ */ new Date() } },
1518
+ { returnDocument: "after" }
1519
+ );
1520
+ return result ? this.toSite(result) : null;
1521
+ }
1522
+ async close() {
1523
+ await this.client.close();
1524
+ }
1525
+ // ─── Helpers ─────────────────────────────────────────────
1526
+ toSite(doc) {
1527
+ return {
1528
+ siteId: doc.site_id,
1529
+ secretKey: doc.secret_key,
1530
+ name: doc.name,
1531
+ domain: doc.domain ?? void 0,
1532
+ allowedOrigins: doc.allowed_origins ?? void 0,
1533
+ createdAt: doc.created_at.toISOString(),
1534
+ updatedAt: doc.updated_at.toISOString()
1535
+ };
1536
+ }
1537
+ };
1538
+
1539
+ // src/geoip.ts
1540
+ var reader = null;
1541
+ var TZ_COUNTRY = {
1542
+ // Americas
1543
+ "America/New_York": "US",
1544
+ "America/Chicago": "US",
1545
+ "America/Denver": "US",
1546
+ "America/Los_Angeles": "US",
1547
+ "America/Anchorage": "US",
1548
+ "Pacific/Honolulu": "US",
1549
+ "America/Phoenix": "US",
1550
+ "America/Detroit": "US",
1551
+ "America/Indiana/Indianapolis": "US",
1552
+ "America/Toronto": "CA",
1553
+ "America/Vancouver": "CA",
1554
+ "America/Edmonton": "CA",
1555
+ "America/Winnipeg": "CA",
1556
+ "America/Halifax": "CA",
1557
+ "America/Montreal": "CA",
1558
+ "America/Mexico_City": "MX",
1559
+ "America/Cancun": "MX",
1560
+ "America/Tijuana": "MX",
1561
+ "America/Sao_Paulo": "BR",
1562
+ "America/Fortaleza": "BR",
1563
+ "America/Manaus": "BR",
1564
+ "America/Argentina/Buenos_Aires": "AR",
1565
+ "America/Bogota": "CO",
1566
+ "America/Santiago": "CL",
1567
+ "America/Lima": "PE",
1568
+ // Europe
1569
+ "Europe/London": "GB",
1570
+ "Europe/Dublin": "IE",
1571
+ "Europe/Paris": "FR",
1572
+ "Europe/Berlin": "DE",
1573
+ "Europe/Amsterdam": "NL",
1574
+ "Europe/Brussels": "BE",
1575
+ "Europe/Zurich": "CH",
1576
+ "Europe/Vienna": "AT",
1577
+ "Europe/Rome": "IT",
1578
+ "Europe/Madrid": "ES",
1579
+ "Europe/Lisbon": "PT",
1580
+ "Europe/Warsaw": "PL",
1581
+ "Europe/Prague": "CZ",
1582
+ "Europe/Budapest": "HU",
1583
+ "Europe/Bucharest": "RO",
1584
+ "Europe/Sofia": "BG",
1585
+ "Europe/Athens": "GR",
1586
+ "Europe/Helsinki": "FI",
1587
+ "Europe/Stockholm": "SE",
1588
+ "Europe/Oslo": "NO",
1589
+ "Europe/Copenhagen": "DK",
1590
+ "Europe/Istanbul": "TR",
1591
+ "Europe/Moscow": "RU",
1592
+ "Europe/Kiev": "UA",
1593
+ "Europe/Belgrade": "RS",
1594
+ "Europe/Zagreb": "HR",
1595
+ // Asia
1596
+ "Asia/Tokyo": "JP",
1597
+ "Asia/Seoul": "KR",
1598
+ "Asia/Shanghai": "CN",
1599
+ "Asia/Hong_Kong": "HK",
1600
+ "Asia/Taipei": "TW",
1601
+ "Asia/Singapore": "SG",
1602
+ "Asia/Kolkata": "IN",
1603
+ "Asia/Mumbai": "IN",
1604
+ "Asia/Karachi": "PK",
1605
+ "Asia/Dubai": "AE",
1606
+ "Asia/Riyadh": "SA",
1607
+ "Asia/Tehran": "IR",
1608
+ "Asia/Baghdad": "IQ",
1609
+ "Asia/Bangkok": "TH",
1610
+ "Asia/Jakarta": "ID",
1611
+ "Asia/Manila": "PH",
1612
+ "Asia/Ho_Chi_Minh": "VN",
1613
+ "Asia/Kuala_Lumpur": "MY",
1614
+ "Asia/Dhaka": "BD",
1615
+ "Asia/Colombo": "LK",
1616
+ "Asia/Jerusalem": "IL",
1617
+ // Oceania
1618
+ "Australia/Sydney": "AU",
1619
+ "Australia/Melbourne": "AU",
1620
+ "Australia/Brisbane": "AU",
1621
+ "Australia/Perth": "AU",
1622
+ "Australia/Adelaide": "AU",
1623
+ "Pacific/Auckland": "NZ",
1624
+ "Pacific/Fiji": "FJ",
1625
+ // Africa
1626
+ "Africa/Cairo": "EG",
1627
+ "Africa/Lagos": "NG",
1628
+ "Africa/Johannesburg": "ZA",
1629
+ "Africa/Nairobi": "KE",
1630
+ "Africa/Casablanca": "MA",
1631
+ "Africa/Algiers": "DZ",
1632
+ "Africa/Accra": "GH",
1633
+ "Africa/Tunis": "TN"
1634
+ };
1635
+ async function initGeoIP(dbPath) {
1636
+ try {
1637
+ const maxmind = await import("maxmind");
1638
+ const path = dbPath || await findGeoLiteDB();
1639
+ if (path) {
1640
+ reader = await maxmind.open(path);
1641
+ }
1642
+ } catch {
1643
+ }
1644
+ }
1645
+ function resolveGeo(ip, timezone) {
1646
+ if (!ip && !timezone) return {};
1647
+ if (reader && ip) {
1648
+ try {
1649
+ const cleanIp = ip.replace(/^::ffff:/, "");
1650
+ const result = reader.get(cleanIp);
1651
+ if (result?.country?.iso_code) {
1652
+ return {
1653
+ country: result.country.iso_code,
1654
+ city: result.city?.names?.en || void 0,
1655
+ region: result.subdivisions?.[0]?.names?.en || void 0
1656
+ };
1657
+ }
1658
+ } catch {
1659
+ }
1660
+ }
1661
+ if (timezone) {
1662
+ const country = TZ_COUNTRY[timezone];
1663
+ if (country) {
1664
+ return { country };
1665
+ }
1666
+ }
1667
+ return {};
1668
+ }
1669
+ async function findGeoLiteDB() {
1670
+ const { existsSync } = await import("fs");
1671
+ const { join } = await import("path");
1672
+ const { homedir } = await import("os");
1673
+ const candidates = [
1674
+ join(process.cwd(), "GeoLite2-City.mmdb"),
1675
+ join(homedir(), ".litemetrics", "GeoLite2-City.mmdb"),
1676
+ "/usr/share/GeoIP/GeoLite2-City.mmdb",
1677
+ "/var/lib/GeoIP/GeoLite2-City.mmdb"
1678
+ ];
1679
+ for (const p of candidates) {
1680
+ if (existsSync(p)) return p;
1681
+ }
1682
+ return null;
1683
+ }
1684
+
1685
+ // src/useragent.ts
1686
+ var import_ua_parser_js = require("ua-parser-js");
1687
+ var parser = new import_ua_parser_js.UAParser();
1688
+ function parseUserAgent(ua) {
1689
+ parser.setUA(ua);
1690
+ const result = parser.getResult();
1691
+ return {
1692
+ type: resolveDeviceType(result.device?.type),
1693
+ browser: result.browser?.name || "Unknown",
1694
+ os: result.os?.name || "Unknown"
1695
+ };
1696
+ }
1697
+ function resolveDeviceType(type) {
1698
+ if (type === "mobile") return "mobile";
1699
+ if (type === "tablet") return "tablet";
1700
+ return "desktop";
1701
+ }
1702
+
1703
+ // src/collector.ts
1704
+ async function createCollector(config) {
1705
+ const db = createAdapter(config.db);
1706
+ await db.init();
1707
+ if (config.geoip) {
1708
+ const geoipConfig = typeof config.geoip === "object" ? config.geoip : {};
1709
+ await initGeoIP(geoipConfig.dbPath);
1710
+ }
1711
+ function isAdmin(req) {
1712
+ if (!config.adminSecret) return false;
1713
+ return req.headers?.["x-litemetrics-admin-secret"] === config.adminSecret;
1714
+ }
1715
+ async function isAuthorizedForSite(req, siteId) {
1716
+ if (isAdmin(req)) return true;
1717
+ const secret = req.headers?.["x-litemetrics-secret"];
1718
+ if (!secret) return false;
1719
+ const site = await db.getSiteBySecret(secret);
1720
+ return site !== null && site.siteId === siteId;
1721
+ }
1722
+ function setCors(req, res, methods, extraHeaders) {
1723
+ if (!config.cors) return false;
1724
+ const origin = req.headers?.origin;
1725
+ const allowed = !config.cors.origins || config.cors.origins.length === 0 || config.cors.origins.includes(origin);
1726
+ if (allowed) {
1727
+ res.setHeader?.("Access-Control-Allow-Origin", origin || "*");
1728
+ res.setHeader?.("Access-Control-Allow-Methods", methods);
1729
+ const headers = ["Content-Type", extraHeaders].filter(Boolean).join(", ");
1730
+ res.setHeader?.("Access-Control-Allow-Headers", headers);
1731
+ }
1732
+ if (req.method === "OPTIONS") {
1733
+ res.writeHead?.(204);
1734
+ res.end?.();
1735
+ return true;
1736
+ }
1737
+ return false;
1738
+ }
1739
+ function enrichEvents(events, ip, userAgent) {
1740
+ const device = parseUserAgent(userAgent);
1741
+ return events.map((event) => {
1742
+ const geo = resolveGeo(ip, event.timezone);
1743
+ return { ...event, ip, geo, device };
1744
+ });
1745
+ }
1746
+ function extractIp(req) {
1747
+ if (config.trustProxy ?? true) {
1748
+ const forwarded = req.headers?.["x-forwarded-for"];
1749
+ if (forwarded) {
1750
+ const first = typeof forwarded === "string" ? forwarded.split(",")[0] : forwarded[0];
1751
+ return first.trim();
1752
+ }
1753
+ if (req.headers?.["x-real-ip"]) return req.headers["x-real-ip"];
1754
+ }
1755
+ return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || "";
1756
+ }
1757
+ function handler() {
1758
+ return async (req, res) => {
1759
+ if (setCors(req, res, "POST, OPTIONS")) return;
1760
+ if (req.method !== "POST") {
1761
+ sendJson(res, 405, { ok: false, error: "Method not allowed" });
1762
+ return;
1763
+ }
1764
+ try {
1765
+ const body = await parseBody(req);
1766
+ const payload = body;
1767
+ if (!payload?.events || !Array.isArray(payload.events) || payload.events.length === 0) {
1768
+ sendJson(res, 400, { ok: false, error: "No events provided" });
1769
+ return;
1770
+ }
1771
+ if (payload.events.length > 100) {
1772
+ sendJson(res, 400, { ok: false, error: "Too many events (max 100)" });
1773
+ return;
1774
+ }
1775
+ const ip = extractIp(req);
1776
+ const userAgent = req.headers?.["user-agent"] || "";
1777
+ const enriched = enrichEvents(payload.events, ip, userAgent);
1778
+ await db.insertEvents(enriched);
1779
+ sendJson(res, 200, { ok: true });
1780
+ } catch (err) {
1781
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
1782
+ }
1783
+ };
1784
+ }
1785
+ function queryHandler() {
1786
+ return async (req, res) => {
1787
+ if (setCors(req, res, "GET, OPTIONS", "X-Litemetrics-Secret, X-Litemetrics-Admin-Secret")) return;
1788
+ try {
1789
+ const params = extractQueryParams(req);
1790
+ if (!params.siteId) {
1791
+ sendJson(res, 400, { ok: false, error: "siteId is required" });
1792
+ return;
1793
+ }
1794
+ if (!params.metric) {
1795
+ sendJson(res, 400, { ok: false, error: "metric is required" });
1796
+ return;
1797
+ }
1798
+ const authorized = await isAuthorizedForSite(req, params.siteId);
1799
+ if (!authorized) {
1800
+ sendJson(res, 401, { ok: false, error: "Invalid or missing secret key" });
1801
+ return;
1802
+ }
1803
+ if (params.metric === "timeseries") {
1804
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
1805
+ const tsParams = {
1806
+ siteId: params.siteId,
1807
+ metric: q.tsMetric || "pageviews",
1808
+ period: params.period,
1809
+ dateFrom: params.dateFrom,
1810
+ dateTo: params.dateTo,
1811
+ granularity: q.granularity
1812
+ };
1813
+ const result2 = await db.queryTimeSeries(tsParams);
1814
+ sendJson(res, 200, result2);
1815
+ return;
1816
+ }
1817
+ if (params.metric === "retention") {
1818
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
1819
+ const retentionParams = {
1820
+ siteId: params.siteId,
1821
+ period: params.period,
1822
+ weeks: q.weeks ? parseInt(q.weeks, 10) : void 0
1823
+ };
1824
+ const result2 = await db.queryRetention(retentionParams);
1825
+ sendJson(res, 200, result2);
1826
+ return;
1827
+ }
1828
+ const result = await db.query(params);
1829
+ sendJson(res, 200, result);
1830
+ } catch (err) {
1831
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
1832
+ }
1833
+ };
1834
+ }
1835
+ function sitesHandler() {
1836
+ return async (req, res) => {
1837
+ if (setCors(req, res, "GET, POST, PUT, DELETE, OPTIONS", "X-Litemetrics-Admin-Secret")) return;
1838
+ if (!isAdmin(req)) {
1839
+ sendJson(res, 401, { ok: false, error: "Unauthorized - invalid or missing admin secret" });
1840
+ return;
1841
+ }
1842
+ try {
1843
+ const method = req.method;
1844
+ const url = new URL(req.url || "/", "http://localhost");
1845
+ const pathSegments = url.pathname.split("/").filter(Boolean);
1846
+ const sitesIdx = pathSegments.indexOf("sites");
1847
+ const siteId = sitesIdx >= 0 ? pathSegments[sitesIdx + 1] : void 0;
1848
+ const action = sitesIdx >= 0 ? pathSegments[sitesIdx + 2] : void 0;
1849
+ if (method === "POST" && siteId && action === "regenerate") {
1850
+ const site = await db.regenerateSecret(siteId);
1851
+ if (!site) {
1852
+ sendJson(res, 404, { ok: false, error: "Site not found" });
1853
+ return;
1854
+ }
1855
+ sendJson(res, 200, { site });
1856
+ return;
1857
+ }
1858
+ if (method === "GET" && !siteId) {
1859
+ const sites = await db.listSites();
1860
+ sendJson(res, 200, { sites, total: sites.length });
1861
+ return;
1862
+ }
1863
+ if (method === "GET" && siteId) {
1864
+ const site = await db.getSite(siteId);
1865
+ if (!site) {
1866
+ sendJson(res, 404, { ok: false, error: "Site not found" });
1867
+ return;
1868
+ }
1869
+ sendJson(res, 200, { site });
1870
+ return;
1871
+ }
1872
+ if (method === "POST" && !siteId) {
1873
+ const body = await parseBody(req);
1874
+ if (!body.name || typeof body.name !== "string" || !body.name.trim()) {
1875
+ sendJson(res, 400, { ok: false, error: "Site name is required" });
1876
+ return;
1877
+ }
1878
+ const site = await db.createSite(body);
1879
+ sendJson(res, 201, { site });
1880
+ return;
1881
+ }
1882
+ if (method === "PUT" && siteId) {
1883
+ const body = await parseBody(req);
1884
+ const site = await db.updateSite(siteId, body);
1885
+ if (!site) {
1886
+ sendJson(res, 404, { ok: false, error: "Site not found" });
1887
+ return;
1888
+ }
1889
+ sendJson(res, 200, { site });
1890
+ return;
1891
+ }
1892
+ if (method === "DELETE" && siteId) {
1893
+ const deleted = await db.deleteSite(siteId);
1894
+ if (!deleted) {
1895
+ sendJson(res, 404, { ok: false, error: "Site not found" });
1896
+ return;
1897
+ }
1898
+ sendJson(res, 200, { ok: true });
1899
+ return;
1900
+ }
1901
+ sendJson(res, 404, { ok: false, error: "Not found" });
1902
+ } catch (err) {
1903
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
1904
+ }
1905
+ };
1906
+ }
1907
+ function eventsHandler() {
1908
+ return async (req, res) => {
1909
+ if (setCors(req, res, "GET, OPTIONS", "X-Litemetrics-Secret, X-Litemetrics-Admin-Secret")) return;
1910
+ if (req.method !== "GET") {
1911
+ sendJson(res, 405, { ok: false, error: "Method not allowed" });
1912
+ return;
1913
+ }
1914
+ try {
1915
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
1916
+ if (!q.siteId) {
1917
+ sendJson(res, 400, { ok: false, error: "siteId is required" });
1918
+ return;
1919
+ }
1920
+ const authorized = await isAuthorizedForSite(req, q.siteId);
1921
+ if (!authorized) {
1922
+ sendJson(res, 401, { ok: false, error: "Invalid or missing secret key" });
1923
+ return;
1924
+ }
1925
+ const params = {
1926
+ siteId: q.siteId,
1927
+ type: q.type,
1928
+ eventName: q.eventName,
1929
+ visitorId: q.visitorId,
1930
+ userId: q.userId,
1931
+ period: q.period,
1932
+ dateFrom: q.dateFrom,
1933
+ dateTo: q.dateTo,
1934
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
1935
+ offset: q.offset ? parseInt(q.offset, 10) : void 0
1936
+ };
1937
+ const result = await db.listEvents(params);
1938
+ sendJson(res, 200, result);
1939
+ } catch (err) {
1940
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
1941
+ }
1942
+ };
1943
+ }
1944
+ function usersHandler() {
1945
+ return async (req, res) => {
1946
+ if (setCors(req, res, "GET, OPTIONS", "X-Litemetrics-Secret, X-Litemetrics-Admin-Secret")) return;
1947
+ if (req.method !== "GET") {
1948
+ sendJson(res, 405, { ok: false, error: "Method not allowed" });
1949
+ return;
1950
+ }
1951
+ try {
1952
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
1953
+ if (!q.siteId) {
1954
+ sendJson(res, 400, { ok: false, error: "siteId is required" });
1955
+ return;
1956
+ }
1957
+ const authorized = await isAuthorizedForSite(req, q.siteId);
1958
+ if (!authorized) {
1959
+ sendJson(res, 401, { ok: false, error: "Invalid or missing secret key" });
1960
+ return;
1961
+ }
1962
+ const url = new URL(req.url || "/", "http://localhost");
1963
+ const pathSegments = url.pathname.split("/").filter(Boolean);
1964
+ const usersIdx = pathSegments.indexOf("users");
1965
+ const visitorId = usersIdx >= 0 ? pathSegments[usersIdx + 1] : void 0;
1966
+ const action = usersIdx >= 0 ? pathSegments[usersIdx + 2] : void 0;
1967
+ if (visitorId && action === "events") {
1968
+ const params2 = {
1969
+ siteId: q.siteId,
1970
+ type: q.type,
1971
+ period: q.period,
1972
+ dateFrom: q.dateFrom,
1973
+ dateTo: q.dateTo,
1974
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
1975
+ offset: q.offset ? parseInt(q.offset, 10) : void 0
1976
+ };
1977
+ const result2 = await db.getUserEvents(q.siteId, decodeURIComponent(visitorId), params2);
1978
+ sendJson(res, 200, result2);
1979
+ return;
1980
+ }
1981
+ if (visitorId) {
1982
+ const user = await db.getUserDetail(q.siteId, decodeURIComponent(visitorId));
1983
+ if (!user) {
1984
+ sendJson(res, 404, { ok: false, error: "User not found" });
1985
+ return;
1986
+ }
1987
+ sendJson(res, 200, { user });
1988
+ return;
1989
+ }
1990
+ const params = {
1991
+ siteId: q.siteId,
1992
+ search: q.search,
1993
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
1994
+ offset: q.offset ? parseInt(q.offset, 10) : void 0
1995
+ };
1996
+ const result = await db.listUsers(params);
1997
+ sendJson(res, 200, result);
1998
+ } catch (err) {
1999
+ sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : "Internal error" });
2000
+ }
2001
+ };
2002
+ }
2003
+ return {
2004
+ handler,
2005
+ queryHandler,
2006
+ eventsHandler,
2007
+ usersHandler,
2008
+ sitesHandler,
2009
+ async query(params) {
2010
+ return db.query(params);
2011
+ },
2012
+ async listEvents(params) {
2013
+ return db.listEvents(params);
2014
+ },
2015
+ async listUsers(params) {
2016
+ return db.listUsers(params);
2017
+ },
2018
+ async getUserDetail(siteId, visitorId) {
2019
+ return db.getUserDetail(siteId, visitorId);
2020
+ },
2021
+ async getUserEvents(siteId, visitorId, params) {
2022
+ return db.getUserEvents(siteId, visitorId, params);
2023
+ },
2024
+ async track(siteId, name, properties, options) {
2025
+ const event = {
2026
+ type: "event",
2027
+ siteId,
2028
+ timestamp: Date.now(),
2029
+ sessionId: "server",
2030
+ visitorId: "server",
2031
+ name,
2032
+ properties,
2033
+ userId: options?.userId,
2034
+ ip: options?.ip,
2035
+ geo: options?.ip ? resolveGeo(options.ip) : void 0
2036
+ };
2037
+ await db.insertEvents([event]);
2038
+ },
2039
+ async identify(siteId, userId, traits, options) {
2040
+ const event = {
2041
+ type: "identify",
2042
+ siteId,
2043
+ timestamp: Date.now(),
2044
+ sessionId: "server",
2045
+ visitorId: "server",
2046
+ userId,
2047
+ traits,
2048
+ ip: options?.ip,
2049
+ geo: options?.ip ? resolveGeo(options.ip) : void 0
2050
+ };
2051
+ await db.insertEvents([event]);
2052
+ },
2053
+ // Programmatic site management
2054
+ createSite: (data) => db.createSite(data),
2055
+ listSites: () => db.listSites(),
2056
+ getSite: (siteId) => db.getSite(siteId),
2057
+ updateSite: (siteId, data) => db.updateSite(siteId, data),
2058
+ deleteSite: (siteId) => db.deleteSite(siteId),
2059
+ regenerateSecret: (siteId) => db.regenerateSecret(siteId),
2060
+ async close() {
2061
+ await db.close();
2062
+ }
2063
+ };
2064
+ }
2065
+ function createAdapter(config) {
2066
+ const adapter = config.adapter ?? "clickhouse";
2067
+ switch (adapter) {
2068
+ case "clickhouse":
2069
+ return new ClickHouseAdapter(config.url);
2070
+ case "mongodb":
2071
+ return new MongoDBAdapter(config.url);
2072
+ default:
2073
+ throw new Error(`Unknown DB adapter: ${adapter}. Supported: clickhouse, mongodb`);
2074
+ }
2075
+ }
2076
+ async function parseBody(req) {
2077
+ if (req.body) return req.body;
2078
+ return new Promise((resolve, reject) => {
2079
+ let data = "";
2080
+ req.on("data", (chunk) => {
2081
+ data += chunk.toString();
2082
+ });
2083
+ req.on("end", () => {
2084
+ try {
2085
+ resolve(JSON.parse(data));
2086
+ } catch {
2087
+ reject(new Error("Invalid JSON"));
2088
+ }
2089
+ });
2090
+ req.on("error", reject);
2091
+ });
2092
+ }
2093
+ function extractQueryParams(req) {
2094
+ const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
2095
+ return {
2096
+ siteId: q.siteId,
2097
+ metric: q.metric,
2098
+ period: q.period,
2099
+ dateFrom: q.dateFrom,
2100
+ dateTo: q.dateTo,
2101
+ limit: q.limit ? parseInt(q.limit, 10) : void 0,
2102
+ filters: q.filters ? JSON.parse(q.filters) : void 0,
2103
+ compare: q.compare === "true" || q.compare === "1"
2104
+ };
2105
+ }
2106
+ function sendJson(res, status, body) {
2107
+ if (typeof res.status === "function" && typeof res.json === "function") {
2108
+ res.status(status).json(body);
2109
+ return;
2110
+ }
2111
+ res.writeHead(status, { "Content-Type": "application/json" });
2112
+ res.end(JSON.stringify(body));
2113
+ }
2114
+ // Annotate the CommonJS export names for ESM import in node:
2115
+ 0 && (module.exports = {
2116
+ ClickHouseAdapter,
2117
+ MongoDBAdapter,
2118
+ createCollector
2119
+ });
2120
+ //# sourceMappingURL=index.cjs.map