@litemetrics/node 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +132 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +132 -66
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -3,6 +3,36 @@ import { createClient } from "@clickhouse/client";
|
|
|
3
3
|
|
|
4
4
|
// src/adapters/utils.ts
|
|
5
5
|
import { randomBytes } from "crypto";
|
|
6
|
+
function getTimezoneOffsetMs(date, timezone) {
|
|
7
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
8
|
+
timeZone: timezone,
|
|
9
|
+
year: "numeric",
|
|
10
|
+
month: "2-digit",
|
|
11
|
+
day: "2-digit",
|
|
12
|
+
hour: "2-digit",
|
|
13
|
+
minute: "2-digit",
|
|
14
|
+
second: "2-digit",
|
|
15
|
+
hour12: false
|
|
16
|
+
});
|
|
17
|
+
const parts = formatter.formatToParts(date);
|
|
18
|
+
const get = (type) => parts.find((p) => p.type === type).value;
|
|
19
|
+
const y = parseInt(get("year"));
|
|
20
|
+
const m = parseInt(get("month")) - 1;
|
|
21
|
+
const d = parseInt(get("day"));
|
|
22
|
+
const h = parseInt(get("hour") === "24" ? "0" : get("hour"));
|
|
23
|
+
const mi = parseInt(get("minute"));
|
|
24
|
+
const s = parseInt(get("second"));
|
|
25
|
+
return Date.UTC(y, m, d, h, mi, s) - date.getTime();
|
|
26
|
+
}
|
|
27
|
+
function toUTCDate(value) {
|
|
28
|
+
if (value instanceof Date) return value;
|
|
29
|
+
if (typeof value === "number") return new Date(value);
|
|
30
|
+
const s = String(value).trim();
|
|
31
|
+
if (s.length >= 10 && !s.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(s)) {
|
|
32
|
+
return /* @__PURE__ */ new Date(s.replace(" ", "T") + "Z");
|
|
33
|
+
}
|
|
34
|
+
return new Date(s);
|
|
35
|
+
}
|
|
6
36
|
function resolvePeriod(q) {
|
|
7
37
|
const now = /* @__PURE__ */ new Date();
|
|
8
38
|
const period = q.period ?? "7d";
|
|
@@ -69,60 +99,66 @@ function granularityToDateFormat(g) {
|
|
|
69
99
|
return "%Y-%m";
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
|
-
function fillBuckets(from, to, granularity, dateFormat, rows) {
|
|
102
|
+
function fillBuckets(from, to, granularity, dateFormat, rows, timezone) {
|
|
73
103
|
const map = new Map(rows.map((r) => [r._id, r.value]));
|
|
74
104
|
const points = [];
|
|
75
|
-
const
|
|
105
|
+
const fromOffset = timezone ? getTimezoneOffsetMs(from, timezone) : 0;
|
|
106
|
+
const toOffset = timezone ? getTimezoneOffsetMs(to, timezone) : 0;
|
|
107
|
+
const current = new Date(from.getTime() + fromOffset);
|
|
108
|
+
const toWall = new Date(to.getTime() + toOffset);
|
|
76
109
|
if (granularity === "hour") {
|
|
77
|
-
current.
|
|
110
|
+
current.setUTCMinutes(0, 0, 0);
|
|
78
111
|
} else if (granularity === "day") {
|
|
79
|
-
current.
|
|
112
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
80
113
|
} else if (granularity === "week") {
|
|
81
|
-
const day = current.
|
|
114
|
+
const day = current.getUTCDay();
|
|
82
115
|
const diff = day === 0 ? -6 : 1 - day;
|
|
83
|
-
current.
|
|
84
|
-
current.
|
|
116
|
+
current.setUTCDate(current.getUTCDate() + diff);
|
|
117
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
85
118
|
} else if (granularity === "month") {
|
|
86
|
-
current.
|
|
87
|
-
current.
|
|
119
|
+
current.setUTCDate(1);
|
|
120
|
+
current.setUTCHours(0, 0, 0, 0);
|
|
88
121
|
}
|
|
89
|
-
while (current <=
|
|
122
|
+
while (current <= toWall) {
|
|
90
123
|
const key = formatDateBucket(current, dateFormat);
|
|
91
|
-
|
|
124
|
+
const approxUtc = new Date(current.getTime() - fromOffset);
|
|
125
|
+
const exactOffset = timezone ? getTimezoneOffsetMs(approxUtc, timezone) : 0;
|
|
126
|
+
const realUtc = new Date(current.getTime() - exactOffset);
|
|
127
|
+
points.push({ date: realUtc.toISOString(), value: map.get(key) ?? 0 });
|
|
92
128
|
if (granularity === "hour") {
|
|
93
|
-
current.
|
|
129
|
+
current.setUTCHours(current.getUTCHours() + 1);
|
|
94
130
|
} else if (granularity === "day") {
|
|
95
|
-
current.
|
|
131
|
+
current.setUTCDate(current.getUTCDate() + 1);
|
|
96
132
|
} else if (granularity === "week") {
|
|
97
|
-
current.
|
|
133
|
+
current.setUTCDate(current.getUTCDate() + 7);
|
|
98
134
|
} else if (granularity === "month") {
|
|
99
|
-
current.
|
|
135
|
+
current.setUTCMonth(current.getUTCMonth() + 1);
|
|
100
136
|
}
|
|
101
137
|
}
|
|
102
138
|
return points;
|
|
103
139
|
}
|
|
104
140
|
function formatDateBucket(date, format) {
|
|
105
|
-
const y = date.
|
|
106
|
-
const m = String(date.
|
|
107
|
-
const d = String(date.
|
|
108
|
-
const h = String(date.
|
|
141
|
+
const y = date.getUTCFullYear();
|
|
142
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
143
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
144
|
+
const h = String(date.getUTCHours()).padStart(2, "0");
|
|
109
145
|
if (format === "%Y-%m-%dT%H:00") return `${y}-${m}-${d}T${h}:00`;
|
|
110
146
|
if (format === "%Y-%m-%d") return `${y}-${m}-${d}`;
|
|
111
147
|
if (format === "%Y-%m") return `${y}-${m}`;
|
|
112
148
|
if (format === "%G-W%V") {
|
|
113
|
-
const jan4 = new Date(y, 0, 4);
|
|
114
|
-
const dayOfYear = Math.ceil((date.getTime() -
|
|
115
|
-
const jan4Day = jan4.
|
|
149
|
+
const jan4 = new Date(Date.UTC(y, 0, 4));
|
|
150
|
+
const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
|
|
151
|
+
const jan4Day = jan4.getUTCDay() || 7;
|
|
116
152
|
const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
|
|
117
153
|
return `${y}-W${String(weekNum).padStart(2, "0")}`;
|
|
118
154
|
}
|
|
119
155
|
return date.toISOString();
|
|
120
156
|
}
|
|
121
157
|
function getISOWeek(date) {
|
|
122
|
-
const y = date.
|
|
123
|
-
const jan4 = new Date(y, 0, 4);
|
|
124
|
-
const dayOfYear = Math.ceil((date.getTime() -
|
|
125
|
-
const jan4Day = jan4.
|
|
158
|
+
const y = date.getUTCFullYear();
|
|
159
|
+
const jan4 = new Date(Date.UTC(y, 0, 4));
|
|
160
|
+
const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
|
|
161
|
+
const jan4Day = jan4.getUTCDay() || 7;
|
|
126
162
|
const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
|
|
127
163
|
return `${y}-W${String(weekNum).padStart(2, "0")}`;
|
|
128
164
|
}
|
|
@@ -137,6 +173,38 @@ function generateSecretKey() {
|
|
|
137
173
|
return `sk_${randomBytes(32).toString("hex")}`;
|
|
138
174
|
}
|
|
139
175
|
|
|
176
|
+
// src/query-helpers.ts
|
|
177
|
+
function isValidTimezone(tz) {
|
|
178
|
+
try {
|
|
179
|
+
Intl.DateTimeFormat(void 0, { timeZone: tz });
|
|
180
|
+
return true;
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function extractQueryParams(req) {
|
|
186
|
+
const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
|
|
187
|
+
let timezone;
|
|
188
|
+
if (typeof q.timezone === "string" && q.timezone) {
|
|
189
|
+
if (isValidTimezone(q.timezone)) {
|
|
190
|
+
timezone = q.timezone;
|
|
191
|
+
} else {
|
|
192
|
+
console.warn(`[litemetrics] Invalid timezone "${q.timezone}", falling back to UTC`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
siteId: q.siteId,
|
|
197
|
+
metric: q.metric,
|
|
198
|
+
period: q.period,
|
|
199
|
+
dateFrom: q.dateFrom,
|
|
200
|
+
dateTo: q.dateTo,
|
|
201
|
+
limit: q.limit ? parseInt(q.limit, 10) : void 0,
|
|
202
|
+
filters: q.filters ? JSON.parse(q.filters) : void 0,
|
|
203
|
+
compare: q.compare === "true" || q.compare === "1",
|
|
204
|
+
timezone
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
140
208
|
// src/adapters/clickhouse.ts
|
|
141
209
|
var EVENTS_TABLE = "litemetrics_events";
|
|
142
210
|
var SITES_TABLE = "litemetrics_sites";
|
|
@@ -221,8 +289,15 @@ CREATE TABLE IF NOT EXISTS ${SITES_TABLE} (
|
|
|
221
289
|
SETTINGS index_granularity = 8192
|
|
222
290
|
`;
|
|
223
291
|
function toCHDateTime(d) {
|
|
224
|
-
const
|
|
225
|
-
|
|
292
|
+
const date = d instanceof Date ? d : toUTCDate(d);
|
|
293
|
+
const y = date.getUTCFullYear();
|
|
294
|
+
const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
295
|
+
const da = String(date.getUTCDate()).padStart(2, "0");
|
|
296
|
+
const h = String(date.getUTCHours()).padStart(2, "0");
|
|
297
|
+
const mi = String(date.getUTCMinutes()).padStart(2, "0");
|
|
298
|
+
const s = String(date.getUTCSeconds()).padStart(2, "0");
|
|
299
|
+
const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
|
|
300
|
+
return `${y}-${mo}-${da} ${h}:${mi}:${s}.${ms}`;
|
|
226
301
|
}
|
|
227
302
|
function normalizedUtmSourceExpr() {
|
|
228
303
|
return `multiIf(
|
|
@@ -942,7 +1017,7 @@ var ClickHouseAdapter = class {
|
|
|
942
1017
|
dateTo: params.dateTo
|
|
943
1018
|
});
|
|
944
1019
|
const granularity = params.granularity ?? autoGranularity(period);
|
|
945
|
-
const bucketFn = this.granularityToClickHouseFunc(granularity);
|
|
1020
|
+
const bucketFn = this.granularityToClickHouseFunc(granularity, params.timezone);
|
|
946
1021
|
const dateFormat = granularityToDateFormat(granularity);
|
|
947
1022
|
const filter = buildFilterConditions(params.filters);
|
|
948
1023
|
const filterSql = filter.conditions.length > 0 ? ` AND ${filter.conditions.join(" AND ")}` : "";
|
|
@@ -991,38 +1066,41 @@ var ClickHouseAdapter = class {
|
|
|
991
1066
|
new Date(dateRange.to),
|
|
992
1067
|
granularity,
|
|
993
1068
|
dateFormat,
|
|
994
|
-
mappedRows
|
|
1069
|
+
mappedRows,
|
|
1070
|
+
params.timezone
|
|
995
1071
|
);
|
|
996
1072
|
return { metric: params.metric, granularity, data };
|
|
997
1073
|
}
|
|
998
|
-
granularityToClickHouseFunc(g) {
|
|
1074
|
+
granularityToClickHouseFunc(g, timezone) {
|
|
1075
|
+
const safeTz = timezone && isValidTimezone(timezone) ? timezone : void 0;
|
|
1076
|
+
const tz = safeTz ? `, '${safeTz}'` : "";
|
|
999
1077
|
switch (g) {
|
|
1000
1078
|
case "hour":
|
|
1001
|
-
return
|
|
1079
|
+
return `toStartOfHour(timestamp${tz})`;
|
|
1002
1080
|
case "day":
|
|
1003
|
-
return
|
|
1081
|
+
return `toStartOfDay(timestamp${tz})`;
|
|
1004
1082
|
case "week":
|
|
1005
|
-
return
|
|
1083
|
+
return `toStartOfWeek(timestamp, 1${tz})`;
|
|
1006
1084
|
// 1 = Monday
|
|
1007
1085
|
case "month":
|
|
1008
|
-
return
|
|
1086
|
+
return `toStartOfMonth(timestamp${tz})`;
|
|
1009
1087
|
}
|
|
1010
1088
|
}
|
|
1011
1089
|
convertClickHouseBucket(bucket, granularity) {
|
|
1012
|
-
const date =
|
|
1013
|
-
const y = date.
|
|
1014
|
-
const m = String(date.
|
|
1015
|
-
const d = String(date.
|
|
1016
|
-
const h = String(date.
|
|
1090
|
+
const date = toUTCDate(bucket);
|
|
1091
|
+
const y = date.getUTCFullYear();
|
|
1092
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
1093
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
1094
|
+
const h = String(date.getUTCHours()).padStart(2, "0");
|
|
1017
1095
|
switch (granularity) {
|
|
1018
1096
|
case "hour":
|
|
1019
1097
|
return `${y}-${m}-${d}T${h}:00`;
|
|
1020
1098
|
case "day":
|
|
1021
1099
|
return `${y}-${m}-${d}`;
|
|
1022
1100
|
case "week": {
|
|
1023
|
-
const jan4 = new Date(y, 0, 4);
|
|
1024
|
-
const dayOfYear = Math.ceil((date.getTime() -
|
|
1025
|
-
const jan4Day = jan4.
|
|
1101
|
+
const jan4 = new Date(Date.UTC(y, 0, 4));
|
|
1102
|
+
const dayOfYear = Math.ceil((date.getTime() - Date.UTC(y, 0, 1)) / 864e5) + 1;
|
|
1103
|
+
const jan4Day = jan4.getUTCDay() || 7;
|
|
1026
1104
|
const weekNum = Math.ceil((dayOfYear + jan4Day - 1) / 7);
|
|
1027
1105
|
return `${y}-W${String(weekNum).padStart(2, "0")}`;
|
|
1028
1106
|
}
|
|
@@ -1051,7 +1129,7 @@ var ClickHouseAdapter = class {
|
|
|
1051
1129
|
);
|
|
1052
1130
|
const cohortMap = /* @__PURE__ */ new Map();
|
|
1053
1131
|
for (const v of rows) {
|
|
1054
|
-
const firstDate =
|
|
1132
|
+
const firstDate = toUTCDate(v.first_event);
|
|
1055
1133
|
const cohortWeek = getISOWeek(firstDate);
|
|
1056
1134
|
if (!cohortMap.has(cohortWeek)) {
|
|
1057
1135
|
cohortMap.set(cohortWeek, { visitors: /* @__PURE__ */ new Set(), weekSets: /* @__PURE__ */ new Map() });
|
|
@@ -1059,7 +1137,7 @@ var ClickHouseAdapter = class {
|
|
|
1059
1137
|
const cohort = cohortMap.get(cohortWeek);
|
|
1060
1138
|
cohort.visitors.add(v.visitor_id);
|
|
1061
1139
|
const eventWeeks = (Array.isArray(v.active_weeks) ? v.active_weeks : []).map((w) => {
|
|
1062
|
-
const d =
|
|
1140
|
+
const d = toUTCDate(w);
|
|
1063
1141
|
return getISOWeek(d);
|
|
1064
1142
|
});
|
|
1065
1143
|
for (const w of eventWeeks) {
|
|
@@ -1226,8 +1304,8 @@ var ClickHouseAdapter = class {
|
|
|
1226
1304
|
visitorId: String(u.visitor_id),
|
|
1227
1305
|
userId: u.userId ? String(u.userId) : void 0,
|
|
1228
1306
|
traits: this.parseJSON(u.traits),
|
|
1229
|
-
firstSeen:
|
|
1230
|
-
lastSeen:
|
|
1307
|
+
firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
|
|
1308
|
+
lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
|
|
1231
1309
|
totalEvents: Number(u.totalEvents),
|
|
1232
1310
|
totalPageviews: Number(u.totalPageviews),
|
|
1233
1311
|
totalSessions: Number(u.totalSessions),
|
|
@@ -1350,8 +1428,8 @@ var ClickHouseAdapter = class {
|
|
|
1350
1428
|
visitorIds,
|
|
1351
1429
|
userId,
|
|
1352
1430
|
traits: this.parseJSON(u.traits),
|
|
1353
|
-
firstSeen:
|
|
1354
|
-
lastSeen:
|
|
1431
|
+
firstSeen: toUTCDate(String(u.firstSeen)).toISOString(),
|
|
1432
|
+
lastSeen: toUTCDate(String(u.lastSeen)).toISOString(),
|
|
1355
1433
|
totalEvents: Number(u.totalEvents),
|
|
1356
1434
|
totalPageviews: Number(u.totalPageviews),
|
|
1357
1435
|
totalSessions: Number(u.totalSessions),
|
|
@@ -1625,15 +1703,15 @@ var ClickHouseAdapter = class {
|
|
|
1625
1703
|
domain: row.domain ? String(row.domain) : void 0,
|
|
1626
1704
|
allowedOrigins: row.allowed_origins ? JSON.parse(String(row.allowed_origins)) : void 0,
|
|
1627
1705
|
conversionEvents: row.conversion_events ? JSON.parse(String(row.conversion_events)) : void 0,
|
|
1628
|
-
createdAt:
|
|
1629
|
-
updatedAt:
|
|
1706
|
+
createdAt: toUTCDate(String(row.created_at)).toISOString(),
|
|
1707
|
+
updatedAt: toUTCDate(String(row.updated_at)).toISOString()
|
|
1630
1708
|
};
|
|
1631
1709
|
}
|
|
1632
1710
|
toEventListItem(row) {
|
|
1633
1711
|
return {
|
|
1634
1712
|
id: String(row.event_id ?? ""),
|
|
1635
1713
|
type: String(row.type),
|
|
1636
|
-
timestamp:
|
|
1714
|
+
timestamp: toUTCDate(String(row.timestamp)).toISOString(),
|
|
1637
1715
|
visitorId: String(row.visitor_id),
|
|
1638
1716
|
sessionId: String(row.session_id),
|
|
1639
1717
|
url: row.url ? String(row.url) : void 0,
|
|
@@ -3289,7 +3367,8 @@ async function createCollector(config) {
|
|
|
3289
3367
|
dateFrom: params.dateFrom,
|
|
3290
3368
|
dateTo: params.dateTo,
|
|
3291
3369
|
granularity: q.granularity,
|
|
3292
|
-
filters: q.filters ? JSON.parse(q.filters) : void 0
|
|
3370
|
+
filters: q.filters ? JSON.parse(q.filters) : void 0,
|
|
3371
|
+
timezone: params.timezone
|
|
3293
3372
|
};
|
|
3294
3373
|
if (tsParams.metric === "conversions") {
|
|
3295
3374
|
const site = await db.getSite(params.siteId);
|
|
@@ -3591,19 +3670,6 @@ async function parseBody(req) {
|
|
|
3591
3670
|
req.on("error", reject);
|
|
3592
3671
|
});
|
|
3593
3672
|
}
|
|
3594
|
-
function extractQueryParams(req) {
|
|
3595
|
-
const q = req.query ?? Object.fromEntries(new URL(req.url, "http://localhost").searchParams);
|
|
3596
|
-
return {
|
|
3597
|
-
siteId: q.siteId,
|
|
3598
|
-
metric: q.metric,
|
|
3599
|
-
period: q.period,
|
|
3600
|
-
dateFrom: q.dateFrom,
|
|
3601
|
-
dateTo: q.dateTo,
|
|
3602
|
-
limit: q.limit ? parseInt(q.limit, 10) : void 0,
|
|
3603
|
-
filters: q.filters ? JSON.parse(q.filters) : void 0,
|
|
3604
|
-
compare: q.compare === "true" || q.compare === "1"
|
|
3605
|
-
};
|
|
3606
|
-
}
|
|
3607
3673
|
function sendJson(res, status, body) {
|
|
3608
3674
|
if (typeof res.status === "function" && typeof res.json === "function") {
|
|
3609
3675
|
res.status(status).json(body);
|