@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 +2120 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +87 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +2081 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
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
|