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