@momentumcms/plugins-analytics 0.1.0 → 0.1.2
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/CHANGELOG.md +16 -0
- package/index.cjs +16 -6
- package/index.js +2195 -0
- package/package.json +55 -55
package/index.js
ADDED
|
@@ -0,0 +1,2195 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// libs/plugins/analytics/src/lib/client/block-tracker.ts
|
|
12
|
+
var block_tracker_exports = {};
|
|
13
|
+
__export(block_tracker_exports, {
|
|
14
|
+
attachBlockTracking: () => attachBlockTracking
|
|
15
|
+
});
|
|
16
|
+
function attachBlockTracking(tracker, container) {
|
|
17
|
+
const root = container ?? document.body;
|
|
18
|
+
const impressionsSeen = /* @__PURE__ */ new Set();
|
|
19
|
+
const hoverCooldowns = /* @__PURE__ */ new Map();
|
|
20
|
+
const HOVER_COOLDOWN_MS = 5e3;
|
|
21
|
+
let observer = null;
|
|
22
|
+
function handleIntersection(entries) {
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (!entry.isIntersecting)
|
|
25
|
+
continue;
|
|
26
|
+
if (!(entry.target instanceof HTMLElement))
|
|
27
|
+
continue;
|
|
28
|
+
const el = entry.target;
|
|
29
|
+
const blockType = el.dataset["blockType"];
|
|
30
|
+
const blockIndex = el.dataset["blockIndex"];
|
|
31
|
+
if (!blockType)
|
|
32
|
+
continue;
|
|
33
|
+
const key = `${blockType}:${blockIndex ?? "?"}`;
|
|
34
|
+
if (impressionsSeen.has(key))
|
|
35
|
+
continue;
|
|
36
|
+
impressionsSeen.add(key);
|
|
37
|
+
tracker.track("block_impression", {
|
|
38
|
+
blockType,
|
|
39
|
+
blockIndex: blockIndex ? Number(blockIndex) : void 0
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
observer = new IntersectionObserver(handleIntersection, {
|
|
44
|
+
threshold: 0.5
|
|
45
|
+
});
|
|
46
|
+
const tracked = Array.from(root.querySelectorAll("[data-block-track]"));
|
|
47
|
+
for (const el of tracked) {
|
|
48
|
+
const trackValue = el.dataset["blockTrack"] ?? "";
|
|
49
|
+
if (trackValue.includes("impressions")) {
|
|
50
|
+
observer.observe(el);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function handleHover(event) {
|
|
54
|
+
const target = event.target;
|
|
55
|
+
if (!(target instanceof HTMLElement))
|
|
56
|
+
return;
|
|
57
|
+
const blockEl = target.closest("[data-block-track]");
|
|
58
|
+
if (!blockEl)
|
|
59
|
+
return;
|
|
60
|
+
const trackValue = blockEl.dataset["blockTrack"] ?? "";
|
|
61
|
+
if (!trackValue.includes("hover"))
|
|
62
|
+
return;
|
|
63
|
+
const blockType = blockEl.dataset["blockType"];
|
|
64
|
+
const blockIndex = blockEl.dataset["blockIndex"];
|
|
65
|
+
if (!blockType)
|
|
66
|
+
return;
|
|
67
|
+
const key = `${blockType}:${blockIndex ?? "?"}`;
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const lastFired = hoverCooldowns.get(key);
|
|
70
|
+
if (lastFired !== void 0 && now - lastFired < HOVER_COOLDOWN_MS)
|
|
71
|
+
return;
|
|
72
|
+
hoverCooldowns.set(key, now);
|
|
73
|
+
tracker.track("block_hover", {
|
|
74
|
+
blockType,
|
|
75
|
+
blockIndex: blockIndex ? Number(blockIndex) : void 0
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
root.addEventListener("mouseenter", handleHover, true);
|
|
79
|
+
return () => {
|
|
80
|
+
observer?.disconnect();
|
|
81
|
+
observer = null;
|
|
82
|
+
root.removeEventListener("mouseenter", handleHover, true);
|
|
83
|
+
impressionsSeen.clear();
|
|
84
|
+
hoverCooldowns.clear();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
var init_block_tracker = __esm({
|
|
88
|
+
"libs/plugins/analytics/src/lib/client/block-tracker.ts"() {
|
|
89
|
+
"use strict";
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// libs/plugins/analytics/src/lib/utils/type-guards.ts
|
|
94
|
+
function isRecord(val) {
|
|
95
|
+
return val != null && typeof val === "object" && !Array.isArray(val);
|
|
96
|
+
}
|
|
97
|
+
var init_type_guards = __esm({
|
|
98
|
+
"libs/plugins/analytics/src/lib/utils/type-guards.ts"() {
|
|
99
|
+
"use strict";
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// libs/plugins/analytics/src/lib/utils/selector-security.ts
|
|
104
|
+
function normalizeCssEscapes(selector) {
|
|
105
|
+
return selector.replace(
|
|
106
|
+
/\\([0-9a-fA-F]{1,6})\s?/g,
|
|
107
|
+
(_, hex) => String.fromCodePoint(parseInt(hex, 16))
|
|
108
|
+
).replace(/\\([^0-9a-fA-F\n])/g, "$1");
|
|
109
|
+
}
|
|
110
|
+
function stripPseudoWrappers(selector) {
|
|
111
|
+
return selector.replace(/:(is|where|not|has|matches)\s*\(/gi, "(");
|
|
112
|
+
}
|
|
113
|
+
function isSelectorBlocked(selector) {
|
|
114
|
+
const normalized = stripPseudoWrappers(normalizeCssEscapes(selector));
|
|
115
|
+
return BLOCKED_SELECTOR_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
116
|
+
}
|
|
117
|
+
var BLOCKED_SELECTOR_PATTERNS;
|
|
118
|
+
var init_selector_security = __esm({
|
|
119
|
+
"libs/plugins/analytics/src/lib/utils/selector-security.ts"() {
|
|
120
|
+
"use strict";
|
|
121
|
+
BLOCKED_SELECTOR_PATTERNS = [
|
|
122
|
+
/type\s*=\s*["']?password/i,
|
|
123
|
+
/type\s*=\s*["']?hidden/i,
|
|
124
|
+
/autocomplete\s*=\s*["']?cc-/i,
|
|
125
|
+
/autocomplete\s*=\s*["']?current-password/i,
|
|
126
|
+
/autocomplete\s*=\s*["']?new-password/i
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// libs/plugins/analytics/src/lib/client/rule-engine.ts
|
|
132
|
+
var rule_engine_exports = {};
|
|
133
|
+
__export(rule_engine_exports, {
|
|
134
|
+
createRuleEngine: () => createRuleEngine,
|
|
135
|
+
matchUrlPattern: () => matchUrlPattern,
|
|
136
|
+
parseRulesResponse: () => parseRulesResponse
|
|
137
|
+
});
|
|
138
|
+
function matchUrlPattern(pattern, pathname) {
|
|
139
|
+
if (pattern === "*")
|
|
140
|
+
return true;
|
|
141
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0GLOBSTAR\0").replace(/\*/g, "[^/]*").replace(/\0GLOBSTAR\0/g, ".*");
|
|
142
|
+
return new RegExp(`^${escaped}$`).test(pathname);
|
|
143
|
+
}
|
|
144
|
+
function parseRulesResponse(data) {
|
|
145
|
+
if (!isRecord(data) || !Array.isArray(data["rules"]))
|
|
146
|
+
return [];
|
|
147
|
+
const result = [];
|
|
148
|
+
for (const item of data["rules"]) {
|
|
149
|
+
if (!isRecord(item))
|
|
150
|
+
continue;
|
|
151
|
+
if (typeof item["selector"] !== "string" || typeof item["eventName"] !== "string")
|
|
152
|
+
continue;
|
|
153
|
+
if (isSelectorBlocked(item["selector"]))
|
|
154
|
+
continue;
|
|
155
|
+
result.push({
|
|
156
|
+
name: typeof item["name"] === "string" ? item["name"] : "",
|
|
157
|
+
selector: item["selector"],
|
|
158
|
+
eventType: typeof item["eventType"] === "string" ? item["eventType"] : "click",
|
|
159
|
+
eventName: item["eventName"],
|
|
160
|
+
urlPattern: typeof item["urlPattern"] === "string" ? item["urlPattern"] : "*",
|
|
161
|
+
properties: isRecord(item["properties"]) ? item["properties"] : {},
|
|
162
|
+
extractProperties: Array.isArray(item["extractProperties"]) ? item["extractProperties"] : void 0,
|
|
163
|
+
active: item["active"] === true,
|
|
164
|
+
rateLimit: typeof item["rateLimit"] === "number" ? item["rateLimit"] : void 0
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
function truncate(val, max) {
|
|
170
|
+
return val.length > max ? val.slice(0, max) : val;
|
|
171
|
+
}
|
|
172
|
+
function extractProperties(el, rule) {
|
|
173
|
+
const props = { ...rule.properties };
|
|
174
|
+
if (!Array.isArray(rule.extractProperties))
|
|
175
|
+
return props;
|
|
176
|
+
for (const raw of rule.extractProperties) {
|
|
177
|
+
if (!isRecord(raw))
|
|
178
|
+
continue;
|
|
179
|
+
const key = typeof raw["key"] === "string" ? raw["key"] : null;
|
|
180
|
+
const source = typeof raw["source"] === "string" ? raw["source"] : null;
|
|
181
|
+
if (!key || !source)
|
|
182
|
+
continue;
|
|
183
|
+
const rawMaxLen = typeof raw["maxLength"] === "number" ? raw["maxLength"] : MAX_EXTRACT_LENGTH;
|
|
184
|
+
const maxLen = Math.min(rawMaxLen, MAX_EXTRACT_LENGTH);
|
|
185
|
+
switch (source) {
|
|
186
|
+
case "text":
|
|
187
|
+
props[key] = truncate(el.textContent?.trim() ?? "", maxLen);
|
|
188
|
+
break;
|
|
189
|
+
case "attribute": {
|
|
190
|
+
const attr = typeof raw["attribute"] === "string" ? raw["attribute"] : "";
|
|
191
|
+
if (BLOCKED_ATTRIBUTES.has(attr.toLowerCase()))
|
|
192
|
+
break;
|
|
193
|
+
props[key] = truncate(el.getAttribute(attr) ?? "", maxLen);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "dataset": {
|
|
197
|
+
const dsKey = typeof raw["attribute"] === "string" ? raw["attribute"] : "";
|
|
198
|
+
props[key] = truncate(el.dataset[dsKey] ?? "", maxLen);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return props;
|
|
204
|
+
}
|
|
205
|
+
function createRuleEngine(tracker, config = {}) {
|
|
206
|
+
const endpoint = config.endpoint ?? "/api/analytics/tracking-rules";
|
|
207
|
+
let rules = [];
|
|
208
|
+
let activeCleanups = [];
|
|
209
|
+
let running = false;
|
|
210
|
+
let navigationCleanup = null;
|
|
211
|
+
const rateLimits = /* @__PURE__ */ new Map();
|
|
212
|
+
function checkRateLimit(rule) {
|
|
213
|
+
if (!rule.rateLimit)
|
|
214
|
+
return true;
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const state = rateLimits.get(rule.eventName);
|
|
217
|
+
if (!state || now >= state.resetAt) {
|
|
218
|
+
rateLimits.set(rule.eventName, { count: 1, resetAt: now + 6e4 });
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (state.count >= rule.rateLimit)
|
|
222
|
+
return false;
|
|
223
|
+
state.count++;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
function fireRule(rule, el) {
|
|
227
|
+
if (!checkRateLimit(rule))
|
|
228
|
+
return;
|
|
229
|
+
tracker.track(rule.eventName, extractProperties(el, rule));
|
|
230
|
+
}
|
|
231
|
+
function attachRulesForUrl(url) {
|
|
232
|
+
for (const cleanup of activeCleanups)
|
|
233
|
+
cleanup();
|
|
234
|
+
activeCleanups = [];
|
|
235
|
+
const pathname = url.startsWith("http") ? new URL(url).pathname : url;
|
|
236
|
+
const matching = rules.filter((r) => r.active && matchUrlPattern(r.urlPattern, pathname));
|
|
237
|
+
if (matching.length === 0)
|
|
238
|
+
return;
|
|
239
|
+
const delegatedTypes = [
|
|
240
|
+
{ eventType: "click", domEvent: "click", capture: false },
|
|
241
|
+
{ eventType: "submit", domEvent: "submit", capture: true },
|
|
242
|
+
{ eventType: "hover", domEvent: "mouseenter", capture: true },
|
|
243
|
+
{ eventType: "focus", domEvent: "focusin", capture: false }
|
|
244
|
+
];
|
|
245
|
+
for (const { eventType, domEvent, capture } of delegatedTypes) {
|
|
246
|
+
const typeRules = matching.filter((r) => r.eventType === eventType);
|
|
247
|
+
if (typeRules.length === 0)
|
|
248
|
+
continue;
|
|
249
|
+
const handler = (e) => {
|
|
250
|
+
const target = e.target;
|
|
251
|
+
if (!(target instanceof HTMLElement))
|
|
252
|
+
return;
|
|
253
|
+
for (const rule of typeRules) {
|
|
254
|
+
try {
|
|
255
|
+
const matched = target.closest(rule.selector);
|
|
256
|
+
if (matched instanceof HTMLElement) {
|
|
257
|
+
fireRule(rule, matched);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
document.body.addEventListener(domEvent, handler, capture);
|
|
264
|
+
activeCleanups.push(() => document.body.removeEventListener(domEvent, handler, capture));
|
|
265
|
+
}
|
|
266
|
+
const scrollRules = matching.filter((r) => r.eventType === "scroll-into-view");
|
|
267
|
+
if (scrollRules.length > 0) {
|
|
268
|
+
const seen = /* @__PURE__ */ new Set();
|
|
269
|
+
const observer = new IntersectionObserver(
|
|
270
|
+
(entries) => {
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (!entry.isIntersecting)
|
|
273
|
+
continue;
|
|
274
|
+
const el = entry.target;
|
|
275
|
+
if (!(el instanceof HTMLElement))
|
|
276
|
+
continue;
|
|
277
|
+
for (const rule of scrollRules) {
|
|
278
|
+
try {
|
|
279
|
+
if (!el.matches(rule.selector))
|
|
280
|
+
continue;
|
|
281
|
+
} catch {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const key = `${rule.eventName}:${rule.selector}`;
|
|
285
|
+
if (seen.has(key))
|
|
286
|
+
continue;
|
|
287
|
+
seen.add(key);
|
|
288
|
+
fireRule(rule, el);
|
|
289
|
+
observer.unobserve(el);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
{ threshold: 0.5 }
|
|
294
|
+
);
|
|
295
|
+
for (const rule of scrollRules) {
|
|
296
|
+
try {
|
|
297
|
+
const elements = Array.from(document.querySelectorAll(rule.selector));
|
|
298
|
+
for (const el of elements)
|
|
299
|
+
observer.observe(el);
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
activeCleanups.push(() => observer.disconnect());
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function setupNavigationDetection() {
|
|
307
|
+
const cleanups = [];
|
|
308
|
+
const popstateHandler = () => {
|
|
309
|
+
if (running)
|
|
310
|
+
attachRulesForUrl(location.pathname);
|
|
311
|
+
};
|
|
312
|
+
window.addEventListener("popstate", popstateHandler);
|
|
313
|
+
cleanups.push(() => window.removeEventListener("popstate", popstateHandler));
|
|
314
|
+
const originalPushState = history.pushState.bind(history);
|
|
315
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
316
|
+
history.pushState = function(...args) {
|
|
317
|
+
originalPushState(...args);
|
|
318
|
+
if (running)
|
|
319
|
+
attachRulesForUrl(location.pathname);
|
|
320
|
+
};
|
|
321
|
+
history.replaceState = function(...args) {
|
|
322
|
+
originalReplaceState(...args);
|
|
323
|
+
if (running)
|
|
324
|
+
attachRulesForUrl(location.pathname);
|
|
325
|
+
};
|
|
326
|
+
cleanups.push(() => {
|
|
327
|
+
history.pushState = originalPushState;
|
|
328
|
+
history.replaceState = originalReplaceState;
|
|
329
|
+
});
|
|
330
|
+
return () => {
|
|
331
|
+
for (const fn of cleanups)
|
|
332
|
+
fn();
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
async start() {
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(endpoint);
|
|
339
|
+
if (!res.ok)
|
|
340
|
+
return;
|
|
341
|
+
const data = await res.json();
|
|
342
|
+
rules = parseRulesResponse(data);
|
|
343
|
+
} catch {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (rules.length === 0)
|
|
347
|
+
return;
|
|
348
|
+
running = true;
|
|
349
|
+
attachRulesForUrl(location.pathname);
|
|
350
|
+
navigationCleanup = setupNavigationDetection();
|
|
351
|
+
},
|
|
352
|
+
stop() {
|
|
353
|
+
running = false;
|
|
354
|
+
for (const cleanup of activeCleanups)
|
|
355
|
+
cleanup();
|
|
356
|
+
activeCleanups = [];
|
|
357
|
+
navigationCleanup?.();
|
|
358
|
+
navigationCleanup = null;
|
|
359
|
+
rateLimits.clear();
|
|
360
|
+
},
|
|
361
|
+
onNavigate(url) {
|
|
362
|
+
if (running)
|
|
363
|
+
attachRulesForUrl(url);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
var BLOCKED_ATTRIBUTES, MAX_EXTRACT_LENGTH;
|
|
368
|
+
var init_rule_engine = __esm({
|
|
369
|
+
"libs/plugins/analytics/src/lib/client/rule-engine.ts"() {
|
|
370
|
+
"use strict";
|
|
371
|
+
init_type_guards();
|
|
372
|
+
init_selector_security();
|
|
373
|
+
BLOCKED_ATTRIBUTES = /* @__PURE__ */ new Set(["value", "password", "autocomplete", "autofill"]);
|
|
374
|
+
MAX_EXTRACT_LENGTH = 200;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// libs/logger/src/lib/log-level.ts
|
|
379
|
+
var LOG_LEVEL_VALUES = {
|
|
380
|
+
debug: 0,
|
|
381
|
+
info: 1,
|
|
382
|
+
warn: 2,
|
|
383
|
+
error: 3,
|
|
384
|
+
fatal: 4,
|
|
385
|
+
silent: 5
|
|
386
|
+
};
|
|
387
|
+
function shouldLog(messageLevel, configuredLevel) {
|
|
388
|
+
return LOG_LEVEL_VALUES[messageLevel] >= LOG_LEVEL_VALUES[configuredLevel];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// libs/logger/src/lib/ansi-colors.ts
|
|
392
|
+
var ANSI = {
|
|
393
|
+
reset: "\x1B[0m",
|
|
394
|
+
bold: "\x1B[1m",
|
|
395
|
+
dim: "\x1B[2m",
|
|
396
|
+
// Foreground colors
|
|
397
|
+
red: "\x1B[31m",
|
|
398
|
+
green: "\x1B[32m",
|
|
399
|
+
yellow: "\x1B[33m",
|
|
400
|
+
blue: "\x1B[34m",
|
|
401
|
+
magenta: "\x1B[35m",
|
|
402
|
+
cyan: "\x1B[36m",
|
|
403
|
+
white: "\x1B[37m",
|
|
404
|
+
gray: "\x1B[90m",
|
|
405
|
+
// Background colors
|
|
406
|
+
bgRed: "\x1B[41m",
|
|
407
|
+
bgYellow: "\x1B[43m"
|
|
408
|
+
};
|
|
409
|
+
function colorize(text2, ...codes) {
|
|
410
|
+
if (codes.length === 0)
|
|
411
|
+
return text2;
|
|
412
|
+
return `${codes.join("")}${text2}${ANSI.reset}`;
|
|
413
|
+
}
|
|
414
|
+
function supportsColor() {
|
|
415
|
+
if (process.env["FORCE_COLOR"] === "1")
|
|
416
|
+
return true;
|
|
417
|
+
if (process.env["NO_COLOR"] !== void 0)
|
|
418
|
+
return false;
|
|
419
|
+
if (process.env["TERM"] === "dumb")
|
|
420
|
+
return false;
|
|
421
|
+
return process.stdout.isTTY === true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// libs/logger/src/lib/formatters.ts
|
|
425
|
+
var LEVEL_COLORS = {
|
|
426
|
+
debug: [ANSI.dim, ANSI.gray],
|
|
427
|
+
info: [ANSI.cyan],
|
|
428
|
+
warn: [ANSI.yellow],
|
|
429
|
+
error: [ANSI.red],
|
|
430
|
+
fatal: [ANSI.bold, ANSI.white, ANSI.bgRed]
|
|
431
|
+
};
|
|
432
|
+
function padLevel(level) {
|
|
433
|
+
return level.toUpperCase().padEnd(5);
|
|
434
|
+
}
|
|
435
|
+
function formatTimestamp(date2) {
|
|
436
|
+
const y = date2.getFullYear();
|
|
437
|
+
const mo = String(date2.getMonth() + 1).padStart(2, "0");
|
|
438
|
+
const d = String(date2.getDate()).padStart(2, "0");
|
|
439
|
+
const h = String(date2.getHours()).padStart(2, "0");
|
|
440
|
+
const mi = String(date2.getMinutes()).padStart(2, "0");
|
|
441
|
+
const s = String(date2.getSeconds()).padStart(2, "0");
|
|
442
|
+
const ms = String(date2.getMilliseconds()).padStart(3, "0");
|
|
443
|
+
return `${y}-${mo}-${d} ${h}:${mi}:${s}.${ms}`;
|
|
444
|
+
}
|
|
445
|
+
function formatData(data) {
|
|
446
|
+
const entries = Object.entries(data);
|
|
447
|
+
if (entries.length === 0)
|
|
448
|
+
return "";
|
|
449
|
+
return " " + entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
|
|
450
|
+
}
|
|
451
|
+
function prettyFormatter(entry) {
|
|
452
|
+
const useColor = supportsColor();
|
|
453
|
+
const level = entry.level;
|
|
454
|
+
const ts = formatTimestamp(entry.timestamp);
|
|
455
|
+
const levelStr = padLevel(entry.level);
|
|
456
|
+
const ctx = `[${entry.context}]`;
|
|
457
|
+
const msg = entry.message;
|
|
458
|
+
const enrichmentStr = entry.enrichments ? formatData(entry.enrichments) : "";
|
|
459
|
+
const dataStr = entry.data ? formatData(entry.data) : "";
|
|
460
|
+
const extra = `${enrichmentStr}${dataStr}`;
|
|
461
|
+
if (useColor) {
|
|
462
|
+
const colors = LEVEL_COLORS[level];
|
|
463
|
+
const coloredLevel = colorize(levelStr, ...colors);
|
|
464
|
+
const coloredCtx = colorize(ctx, ANSI.magenta);
|
|
465
|
+
const coloredTs = colorize(ts, ANSI.gray);
|
|
466
|
+
return `${coloredTs} ${coloredLevel} ${coloredCtx} ${msg}${extra}
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
return `${ts} ${levelStr} ${ctx} ${msg}${extra}
|
|
470
|
+
`;
|
|
471
|
+
}
|
|
472
|
+
function jsonFormatter(entry) {
|
|
473
|
+
const output = {
|
|
474
|
+
timestamp: entry.timestamp.toISOString(),
|
|
475
|
+
level: entry.level,
|
|
476
|
+
context: entry.context,
|
|
477
|
+
message: entry.message
|
|
478
|
+
};
|
|
479
|
+
if (entry.enrichments && Object.keys(entry.enrichments).length > 0) {
|
|
480
|
+
Object.assign(output, entry.enrichments);
|
|
481
|
+
}
|
|
482
|
+
if (entry.data && Object.keys(entry.data).length > 0) {
|
|
483
|
+
output["data"] = entry.data;
|
|
484
|
+
}
|
|
485
|
+
return JSON.stringify(output) + "\n";
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// libs/logger/src/lib/logger-config.types.ts
|
|
489
|
+
function resolveLoggingConfig(config) {
|
|
490
|
+
return {
|
|
491
|
+
level: config?.level ?? "info",
|
|
492
|
+
format: config?.format ?? "pretty",
|
|
493
|
+
timestamps: config?.timestamps ?? true,
|
|
494
|
+
output: config?.output ?? ((msg) => {
|
|
495
|
+
process.stdout.write(msg);
|
|
496
|
+
}),
|
|
497
|
+
errorOutput: config?.errorOutput ?? ((msg) => {
|
|
498
|
+
process.stderr.write(msg);
|
|
499
|
+
})
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// libs/logger/src/lib/logger.ts
|
|
504
|
+
var ERROR_LEVELS = /* @__PURE__ */ new Set(["warn", "error", "fatal"]);
|
|
505
|
+
var MomentumLogger = class _MomentumLogger {
|
|
506
|
+
static {
|
|
507
|
+
this.enrichers = [];
|
|
508
|
+
}
|
|
509
|
+
constructor(context, config) {
|
|
510
|
+
this.context = context;
|
|
511
|
+
this.config = isResolvedConfig(config) ? config : resolveLoggingConfig(config);
|
|
512
|
+
this.formatter = this.config.format === "json" ? jsonFormatter : prettyFormatter;
|
|
513
|
+
}
|
|
514
|
+
debug(message, data) {
|
|
515
|
+
this.log("debug", message, data);
|
|
516
|
+
}
|
|
517
|
+
info(message, data) {
|
|
518
|
+
this.log("info", message, data);
|
|
519
|
+
}
|
|
520
|
+
warn(message, data) {
|
|
521
|
+
this.log("warn", message, data);
|
|
522
|
+
}
|
|
523
|
+
error(message, data) {
|
|
524
|
+
this.log("error", message, data);
|
|
525
|
+
}
|
|
526
|
+
fatal(message, data) {
|
|
527
|
+
this.log("fatal", message, data);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Creates a child logger with a sub-context.
|
|
531
|
+
* e.g., `Momentum:DB` → `Momentum:DB:Migrate`
|
|
532
|
+
*/
|
|
533
|
+
child(subContext) {
|
|
534
|
+
return new _MomentumLogger(`${this.context}:${subContext}`, this.config);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Registers a global enricher that adds extra fields to all log entries.
|
|
538
|
+
*/
|
|
539
|
+
static registerEnricher(enricher) {
|
|
540
|
+
_MomentumLogger.enrichers.push(enricher);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Removes a previously registered enricher.
|
|
544
|
+
*/
|
|
545
|
+
static removeEnricher(enricher) {
|
|
546
|
+
const index = _MomentumLogger.enrichers.indexOf(enricher);
|
|
547
|
+
if (index >= 0) {
|
|
548
|
+
_MomentumLogger.enrichers.splice(index, 1);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Clears all registered enrichers. Primarily for testing.
|
|
553
|
+
*/
|
|
554
|
+
static clearEnrichers() {
|
|
555
|
+
_MomentumLogger.enrichers.length = 0;
|
|
556
|
+
}
|
|
557
|
+
log(level, message, data) {
|
|
558
|
+
if (!shouldLog(level, this.config.level))
|
|
559
|
+
return;
|
|
560
|
+
const enrichments = this.collectEnrichments();
|
|
561
|
+
const entry = {
|
|
562
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
563
|
+
level,
|
|
564
|
+
context: this.context,
|
|
565
|
+
message,
|
|
566
|
+
data,
|
|
567
|
+
enrichments: Object.keys(enrichments).length > 0 ? enrichments : void 0
|
|
568
|
+
};
|
|
569
|
+
const formatted = this.formatter(entry);
|
|
570
|
+
if (ERROR_LEVELS.has(level)) {
|
|
571
|
+
this.config.errorOutput(formatted);
|
|
572
|
+
} else {
|
|
573
|
+
this.config.output(formatted);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
collectEnrichments() {
|
|
577
|
+
const result = {};
|
|
578
|
+
for (const enricher of _MomentumLogger.enrichers) {
|
|
579
|
+
Object.assign(result, enricher.enrich());
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
function isResolvedConfig(config) {
|
|
585
|
+
if (!config)
|
|
586
|
+
return false;
|
|
587
|
+
return typeof config.level === "string" && typeof config.format === "string" && typeof config.timestamps === "boolean" && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- type guard narrows union
|
|
588
|
+
typeof config.output === "function" && // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- type guard narrows union
|
|
589
|
+
typeof config.errorOutput === "function";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// libs/logger/src/lib/logger-singleton.ts
|
|
593
|
+
var loggerInstance = null;
|
|
594
|
+
var ROOT_CONTEXT = "Momentum";
|
|
595
|
+
function getMomentumLogger() {
|
|
596
|
+
if (!loggerInstance) {
|
|
597
|
+
loggerInstance = new MomentumLogger(ROOT_CONTEXT);
|
|
598
|
+
}
|
|
599
|
+
return loggerInstance;
|
|
600
|
+
}
|
|
601
|
+
function createLogger(context) {
|
|
602
|
+
return getMomentumLogger().child(context);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// libs/plugins/analytics/src/lib/event-store.ts
|
|
606
|
+
var EventStore = class {
|
|
607
|
+
constructor(options) {
|
|
608
|
+
this.buffer = [];
|
|
609
|
+
this.timer = null;
|
|
610
|
+
this.flushing = false;
|
|
611
|
+
this.adapter = options.adapter;
|
|
612
|
+
this.flushInterval = options.flushInterval ?? 5e3;
|
|
613
|
+
this.flushBatchSize = options.flushBatchSize ?? 100;
|
|
614
|
+
this.logger = createLogger("Analytics");
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Start the periodic flush timer.
|
|
618
|
+
*/
|
|
619
|
+
start() {
|
|
620
|
+
if (this.timer)
|
|
621
|
+
return;
|
|
622
|
+
this.timer = setInterval(() => {
|
|
623
|
+
void this.flush();
|
|
624
|
+
}, this.flushInterval);
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Add an event to the buffer.
|
|
628
|
+
* Triggers an immediate flush if batch size is reached.
|
|
629
|
+
*/
|
|
630
|
+
add(event) {
|
|
631
|
+
this.buffer.push(event);
|
|
632
|
+
if (this.buffer.length >= this.flushBatchSize) {
|
|
633
|
+
void this.flush();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Add multiple events to the buffer.
|
|
638
|
+
*/
|
|
639
|
+
addBatch(events) {
|
|
640
|
+
this.buffer.push(...events);
|
|
641
|
+
if (this.buffer.length >= this.flushBatchSize) {
|
|
642
|
+
void this.flush();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Flush all buffered events to the adapter.
|
|
647
|
+
*/
|
|
648
|
+
async flush() {
|
|
649
|
+
if (this.flushing || this.buffer.length === 0)
|
|
650
|
+
return;
|
|
651
|
+
this.flushing = true;
|
|
652
|
+
const events = this.buffer.splice(0, this.buffer.length);
|
|
653
|
+
try {
|
|
654
|
+
await this.adapter.store(events);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
657
|
+
this.logger.error(`Failed to flush ${events.length} events: ${message}`);
|
|
658
|
+
this.buffer.unshift(...events);
|
|
659
|
+
} finally {
|
|
660
|
+
this.flushing = false;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Stop the periodic flush timer and flush remaining events.
|
|
665
|
+
*/
|
|
666
|
+
async shutdown() {
|
|
667
|
+
if (this.timer) {
|
|
668
|
+
clearInterval(this.timer);
|
|
669
|
+
this.timer = null;
|
|
670
|
+
}
|
|
671
|
+
await this.flush();
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Get the current buffer size.
|
|
675
|
+
*/
|
|
676
|
+
get size() {
|
|
677
|
+
return this.buffer.length;
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
// libs/plugins/analytics/src/lib/collectors/collection-collector.ts
|
|
682
|
+
import { randomUUID } from "node:crypto";
|
|
683
|
+
function injectCollectionCollector(collections, emitter, options = {}) {
|
|
684
|
+
const excluded = new Set(options.excludeCollections ?? []);
|
|
685
|
+
for (const collection of collections) {
|
|
686
|
+
if (excluded.has(collection.slug))
|
|
687
|
+
continue;
|
|
688
|
+
collection.hooks = collection.hooks ?? {};
|
|
689
|
+
const afterChangeHook = (args) => {
|
|
690
|
+
const operation = args.operation ?? "create";
|
|
691
|
+
const doc = args.doc ?? args.data ?? {};
|
|
692
|
+
const event = {
|
|
693
|
+
id: randomUUID(),
|
|
694
|
+
category: "content",
|
|
695
|
+
name: operation === "create" ? "content_created" : "content_updated",
|
|
696
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
697
|
+
properties: {
|
|
698
|
+
documentId: doc["id"]
|
|
699
|
+
},
|
|
700
|
+
context: {
|
|
701
|
+
source: "server",
|
|
702
|
+
collection: collection.slug,
|
|
703
|
+
operation
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
emitter(event);
|
|
707
|
+
};
|
|
708
|
+
const existingAfterChange = collection.hooks.afterChange ?? [];
|
|
709
|
+
collection.hooks.afterChange = [...existingAfterChange, afterChangeHook];
|
|
710
|
+
const afterDeleteHook = (args) => {
|
|
711
|
+
const doc = args.doc ?? {};
|
|
712
|
+
const event = {
|
|
713
|
+
id: randomUUID(),
|
|
714
|
+
category: "content",
|
|
715
|
+
name: "content_deleted",
|
|
716
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
717
|
+
properties: {
|
|
718
|
+
documentId: doc["id"]
|
|
719
|
+
},
|
|
720
|
+
context: {
|
|
721
|
+
source: "server",
|
|
722
|
+
collection: collection.slug,
|
|
723
|
+
operation: "delete"
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
emitter(event);
|
|
727
|
+
};
|
|
728
|
+
const existingAfterDelete = collection.hooks.afterDelete ?? [];
|
|
729
|
+
collection.hooks.afterDelete = [...existingAfterDelete, afterDeleteHook];
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// libs/plugins/analytics/src/lib/collectors/api-collector.ts
|
|
734
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
735
|
+
|
|
736
|
+
// libs/plugins/analytics/src/lib/utils/parse-user-agent.ts
|
|
737
|
+
function parseUserAgent(ua) {
|
|
738
|
+
if (!ua) {
|
|
739
|
+
return { device: "unknown", browser: "unknown", os: "unknown" };
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
device: detectDevice(ua),
|
|
743
|
+
browser: detectBrowser(ua),
|
|
744
|
+
os: detectOS(ua)
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function detectDevice(ua) {
|
|
748
|
+
if (/iPad|tablet|Kindle|PlayBook/i.test(ua))
|
|
749
|
+
return "tablet";
|
|
750
|
+
if (/Mobile|Android.*Mobile|iPhone|iPod|Opera Mini|IEMobile/i.test(ua))
|
|
751
|
+
return "mobile";
|
|
752
|
+
return "desktop";
|
|
753
|
+
}
|
|
754
|
+
function detectBrowser(ua) {
|
|
755
|
+
if (/Edg\//i.test(ua))
|
|
756
|
+
return "Edge";
|
|
757
|
+
if (/OPR\//i.test(ua) || /Opera/i.test(ua))
|
|
758
|
+
return "Opera";
|
|
759
|
+
if (/SamsungBrowser/i.test(ua))
|
|
760
|
+
return "Samsung Internet";
|
|
761
|
+
if (/UCBrowser/i.test(ua))
|
|
762
|
+
return "UC Browser";
|
|
763
|
+
if (/Firefox\//i.test(ua))
|
|
764
|
+
return "Firefox";
|
|
765
|
+
if (/Chrome\//i.test(ua) && !/Chromium/i.test(ua))
|
|
766
|
+
return "Chrome";
|
|
767
|
+
if (/Chromium\//i.test(ua))
|
|
768
|
+
return "Chromium";
|
|
769
|
+
if (/Safari\//i.test(ua) && !/Chrome/i.test(ua))
|
|
770
|
+
return "Safari";
|
|
771
|
+
if (/MSIE|Trident/i.test(ua))
|
|
772
|
+
return "IE";
|
|
773
|
+
return "unknown";
|
|
774
|
+
}
|
|
775
|
+
function detectOS(ua) {
|
|
776
|
+
if (/iPhone|iPad|iPod/i.test(ua))
|
|
777
|
+
return "iOS";
|
|
778
|
+
if (/Android/i.test(ua))
|
|
779
|
+
return "Android";
|
|
780
|
+
if (/Windows NT/i.test(ua))
|
|
781
|
+
return "Windows";
|
|
782
|
+
if (/Mac OS X|Macintosh/i.test(ua))
|
|
783
|
+
return "macOS";
|
|
784
|
+
if (/CrOS/i.test(ua))
|
|
785
|
+
return "ChromeOS";
|
|
786
|
+
if (/Linux/i.test(ua))
|
|
787
|
+
return "Linux";
|
|
788
|
+
return "unknown";
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// libs/plugins/analytics/src/lib/collectors/api-collector.ts
|
|
792
|
+
function createApiCollectorMiddleware(emitter) {
|
|
793
|
+
return (req, res, next) => {
|
|
794
|
+
const start = Date.now();
|
|
795
|
+
res.on("finish", () => {
|
|
796
|
+
const duration = Date.now() - start;
|
|
797
|
+
const ua = req.headers["user-agent"];
|
|
798
|
+
const parsed = parseUserAgent(ua);
|
|
799
|
+
const refHeader = req.headers["referer"] ?? req.headers["referrer"];
|
|
800
|
+
const referrer = Array.isArray(refHeader) ? refHeader[0] : refHeader;
|
|
801
|
+
const event = {
|
|
802
|
+
id: randomUUID2(),
|
|
803
|
+
category: "api",
|
|
804
|
+
name: "api_request",
|
|
805
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
806
|
+
properties: {
|
|
807
|
+
method: req.method,
|
|
808
|
+
path: req.path,
|
|
809
|
+
route: req.route?.path
|
|
810
|
+
},
|
|
811
|
+
context: {
|
|
812
|
+
source: "server",
|
|
813
|
+
url: req.originalUrl,
|
|
814
|
+
referrer,
|
|
815
|
+
userAgent: ua,
|
|
816
|
+
ip: req.ip ?? req.socket.remoteAddress,
|
|
817
|
+
device: parsed.device,
|
|
818
|
+
browser: parsed.browser,
|
|
819
|
+
os: parsed.os,
|
|
820
|
+
duration,
|
|
821
|
+
statusCode: res.statusCode
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
emitter(event);
|
|
825
|
+
});
|
|
826
|
+
next();
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// libs/plugins/analytics/src/lib/ingest-handler.ts
|
|
831
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
832
|
+
import { Router as createRouter } from "express";
|
|
833
|
+
|
|
834
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
835
|
+
function defineCollection(config) {
|
|
836
|
+
const collection = {
|
|
837
|
+
timestamps: true,
|
|
838
|
+
// Enable timestamps by default
|
|
839
|
+
...config
|
|
840
|
+
};
|
|
841
|
+
if (!collection.slug) {
|
|
842
|
+
throw new Error("Collection must have a slug");
|
|
843
|
+
}
|
|
844
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
845
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
846
|
+
}
|
|
847
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
return collection;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
856
|
+
function text(name, options = {}) {
|
|
857
|
+
return {
|
|
858
|
+
name,
|
|
859
|
+
type: "text",
|
|
860
|
+
...options
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function number(name, options = {}) {
|
|
864
|
+
return {
|
|
865
|
+
name,
|
|
866
|
+
type: "number",
|
|
867
|
+
...options
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function checkbox(name, options = {}) {
|
|
871
|
+
return {
|
|
872
|
+
name,
|
|
873
|
+
type: "checkbox",
|
|
874
|
+
...options,
|
|
875
|
+
defaultValue: options.defaultValue ?? false
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function select(name, options) {
|
|
879
|
+
return {
|
|
880
|
+
name,
|
|
881
|
+
type: "select",
|
|
882
|
+
...options
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function group(name, options) {
|
|
886
|
+
return {
|
|
887
|
+
name,
|
|
888
|
+
type: "group",
|
|
889
|
+
...options
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function json(name, options = {}) {
|
|
893
|
+
return {
|
|
894
|
+
name,
|
|
895
|
+
type: "json",
|
|
896
|
+
...options
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
901
|
+
var MediaCollection = defineCollection({
|
|
902
|
+
slug: "media",
|
|
903
|
+
labels: {
|
|
904
|
+
singular: "Media",
|
|
905
|
+
plural: "Media"
|
|
906
|
+
},
|
|
907
|
+
admin: {
|
|
908
|
+
useAsTitle: "filename",
|
|
909
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
910
|
+
},
|
|
911
|
+
fields: [
|
|
912
|
+
text("filename", {
|
|
913
|
+
required: true,
|
|
914
|
+
label: "Filename",
|
|
915
|
+
description: "Original filename of the uploaded file"
|
|
916
|
+
}),
|
|
917
|
+
text("mimeType", {
|
|
918
|
+
required: true,
|
|
919
|
+
label: "MIME Type",
|
|
920
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
921
|
+
}),
|
|
922
|
+
number("filesize", {
|
|
923
|
+
label: "File Size",
|
|
924
|
+
description: "File size in bytes"
|
|
925
|
+
}),
|
|
926
|
+
text("path", {
|
|
927
|
+
required: true,
|
|
928
|
+
label: "Storage Path",
|
|
929
|
+
description: "Path/key where the file is stored",
|
|
930
|
+
admin: {
|
|
931
|
+
hidden: true
|
|
932
|
+
}
|
|
933
|
+
}),
|
|
934
|
+
text("url", {
|
|
935
|
+
label: "URL",
|
|
936
|
+
description: "Public URL to access the file"
|
|
937
|
+
}),
|
|
938
|
+
text("alt", {
|
|
939
|
+
label: "Alt Text",
|
|
940
|
+
description: "Alternative text for accessibility"
|
|
941
|
+
}),
|
|
942
|
+
number("width", {
|
|
943
|
+
label: "Width",
|
|
944
|
+
description: "Image width in pixels (for images only)"
|
|
945
|
+
}),
|
|
946
|
+
number("height", {
|
|
947
|
+
label: "Height",
|
|
948
|
+
description: "Image height in pixels (for images only)"
|
|
949
|
+
}),
|
|
950
|
+
json("focalPoint", {
|
|
951
|
+
label: "Focal Point",
|
|
952
|
+
description: "Focal point coordinates for image cropping",
|
|
953
|
+
admin: {
|
|
954
|
+
hidden: true
|
|
955
|
+
}
|
|
956
|
+
})
|
|
957
|
+
],
|
|
958
|
+
access: {
|
|
959
|
+
// Media is readable by anyone by default
|
|
960
|
+
read: () => true,
|
|
961
|
+
// Only authenticated users can create/update/delete
|
|
962
|
+
create: ({ req }) => !!req?.user,
|
|
963
|
+
update: ({ req }) => !!req?.user,
|
|
964
|
+
delete: ({ req }) => !!req?.user
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// libs/core/src/lib/access/access-helpers.ts
|
|
969
|
+
function hasRole(role) {
|
|
970
|
+
return ({ req }) => req.user?.role === role;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// libs/server-core/src/lib/webhooks.ts
|
|
974
|
+
var webhookLogger = createLogger("Webhook");
|
|
975
|
+
|
|
976
|
+
// libs/server-core/src/lib/graphql-schema.ts
|
|
977
|
+
import {
|
|
978
|
+
GraphQLSchema,
|
|
979
|
+
GraphQLObjectType,
|
|
980
|
+
GraphQLInputObjectType,
|
|
981
|
+
GraphQLString,
|
|
982
|
+
GraphQLInt,
|
|
983
|
+
GraphQLFloat,
|
|
984
|
+
GraphQLBoolean,
|
|
985
|
+
GraphQLList,
|
|
986
|
+
GraphQLNonNull,
|
|
987
|
+
GraphQLID,
|
|
988
|
+
GraphQLUnionType,
|
|
989
|
+
GraphQLEnumType
|
|
990
|
+
} from "graphql";
|
|
991
|
+
|
|
992
|
+
// libs/server-core/src/lib/graphql-scalars.ts
|
|
993
|
+
import { GraphQLScalarType, Kind } from "graphql";
|
|
994
|
+
var GraphQLJSON = new GraphQLScalarType({
|
|
995
|
+
name: "JSON",
|
|
996
|
+
description: "Arbitrary JSON value",
|
|
997
|
+
serialize(value) {
|
|
998
|
+
return value;
|
|
999
|
+
},
|
|
1000
|
+
parseValue(value) {
|
|
1001
|
+
return value;
|
|
1002
|
+
},
|
|
1003
|
+
parseLiteral(ast) {
|
|
1004
|
+
switch (ast.kind) {
|
|
1005
|
+
case Kind.STRING:
|
|
1006
|
+
return ast.value;
|
|
1007
|
+
case Kind.BOOLEAN:
|
|
1008
|
+
return ast.value;
|
|
1009
|
+
case Kind.INT:
|
|
1010
|
+
return parseInt(ast.value, 10);
|
|
1011
|
+
case Kind.FLOAT:
|
|
1012
|
+
return parseFloat(ast.value);
|
|
1013
|
+
case Kind.OBJECT: {
|
|
1014
|
+
const value = {};
|
|
1015
|
+
for (const field of ast.fields) {
|
|
1016
|
+
value[field.name.value] = GraphQLJSON.parseLiteral(field.value, void 0);
|
|
1017
|
+
}
|
|
1018
|
+
return value;
|
|
1019
|
+
}
|
|
1020
|
+
case Kind.LIST:
|
|
1021
|
+
return ast.values.map((v) => GraphQLJSON.parseLiteral(v, void 0));
|
|
1022
|
+
case Kind.NULL:
|
|
1023
|
+
return null;
|
|
1024
|
+
default:
|
|
1025
|
+
return void 0;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// libs/server-core/src/lib/graphql-handler.ts
|
|
1031
|
+
import { graphql, parse, validate, GraphQLError } from "graphql";
|
|
1032
|
+
|
|
1033
|
+
// libs/server-core/src/lib/rate-limiter.ts
|
|
1034
|
+
var RateLimiter = class _RateLimiter {
|
|
1035
|
+
constructor(maxPerMinute, windowMs = 6e4) {
|
|
1036
|
+
this.limits = /* @__PURE__ */ new Map();
|
|
1037
|
+
this.lastCleanup = 0;
|
|
1038
|
+
this.maxPerMinute = maxPerMinute;
|
|
1039
|
+
this.windowMs = windowMs;
|
|
1040
|
+
}
|
|
1041
|
+
static {
|
|
1042
|
+
this.CLEANUP_INTERVAL = 6e4;
|
|
1043
|
+
}
|
|
1044
|
+
/** Number of tracked entries (for monitoring/testing). */
|
|
1045
|
+
get size() {
|
|
1046
|
+
return this.limits.size;
|
|
1047
|
+
}
|
|
1048
|
+
/** Check if a request from the given key is allowed. */
|
|
1049
|
+
isAllowed(key) {
|
|
1050
|
+
const now = Date.now();
|
|
1051
|
+
this.maybeCleanup(now);
|
|
1052
|
+
const entry = this.limits.get(key);
|
|
1053
|
+
if (!entry || now >= entry.resetAt) {
|
|
1054
|
+
this.limits.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
if (entry.count >= this.maxPerMinute) {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
entry.count++;
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
/** Evict expired entries if enough time has passed since last cleanup. */
|
|
1064
|
+
maybeCleanup(now) {
|
|
1065
|
+
if (now - this.lastCleanup < _RateLimiter.CLEANUP_INTERVAL) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
this.lastCleanup = now;
|
|
1069
|
+
for (const [key, entry] of this.limits) {
|
|
1070
|
+
if (now >= entry.resetAt) {
|
|
1071
|
+
this.limits.delete(key);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// libs/plugins/analytics/src/lib/ingest-handler.ts
|
|
1078
|
+
var VALID_CATEGORIES = /* @__PURE__ */ new Set([
|
|
1079
|
+
"admin",
|
|
1080
|
+
"api",
|
|
1081
|
+
"content",
|
|
1082
|
+
"page",
|
|
1083
|
+
"action",
|
|
1084
|
+
"custom"
|
|
1085
|
+
]);
|
|
1086
|
+
function isValidClientEvent(event) {
|
|
1087
|
+
if (event === null || typeof event !== "object")
|
|
1088
|
+
return false;
|
|
1089
|
+
const e = event;
|
|
1090
|
+
if (typeof e["name"] !== "string" || e["name"].length === 0)
|
|
1091
|
+
return false;
|
|
1092
|
+
if (e["category"] && !VALID_CATEGORIES.has(e["category"]))
|
|
1093
|
+
return false;
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
function createIngestRouter(options) {
|
|
1097
|
+
const { eventStore, rateLimit = 100 } = options;
|
|
1098
|
+
const limiter = new RateLimiter(rateLimit);
|
|
1099
|
+
const logger = createLogger("Analytics:Ingest");
|
|
1100
|
+
const router = createRouter();
|
|
1101
|
+
router.post("/", (req, res) => {
|
|
1102
|
+
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
1103
|
+
if (!limiter.isAllowed(ip)) {
|
|
1104
|
+
res.status(429).json({ error: "Rate limit exceeded" });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const body = req.body;
|
|
1108
|
+
if (!body || !Array.isArray(body["events"])) {
|
|
1109
|
+
res.status(400).json({ error: "Request body must contain an events array" });
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
const rawEvents = body["events"];
|
|
1113
|
+
const validEvents = [];
|
|
1114
|
+
const ua = req.headers["user-agent"];
|
|
1115
|
+
const parsed = parseUserAgent(ua);
|
|
1116
|
+
const clientIp = req.ip ?? req.socket.remoteAddress;
|
|
1117
|
+
for (const raw of rawEvents) {
|
|
1118
|
+
if (!isValidClientEvent(raw)) {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
const partial = raw;
|
|
1122
|
+
const event = {
|
|
1123
|
+
id: randomUUID3(),
|
|
1124
|
+
category: partial.category ?? "custom",
|
|
1125
|
+
name: partial.name ?? "unknown",
|
|
1126
|
+
// Server-side timestamp (prevents client clock skew)
|
|
1127
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1128
|
+
sessionId: typeof partial.sessionId === "string" ? partial.sessionId : void 0,
|
|
1129
|
+
userId: typeof partial.userId === "string" ? partial.userId : void 0,
|
|
1130
|
+
visitorId: typeof partial.visitorId === "string" ? partial.visitorId : void 0,
|
|
1131
|
+
properties: typeof partial.properties === "object" && partial.properties !== null ? partial.properties : {},
|
|
1132
|
+
context: {
|
|
1133
|
+
source: "client",
|
|
1134
|
+
url: typeof partial.context?.url === "string" ? partial.context.url : void 0,
|
|
1135
|
+
referrer: typeof partial.context?.referrer === "string" ? partial.context.referrer : void 0,
|
|
1136
|
+
userAgent: ua,
|
|
1137
|
+
ip: clientIp,
|
|
1138
|
+
device: parsed.device,
|
|
1139
|
+
browser: parsed.browser,
|
|
1140
|
+
os: parsed.os
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
validEvents.push(event);
|
|
1144
|
+
}
|
|
1145
|
+
if (validEvents.length === 0) {
|
|
1146
|
+
res.status(400).json({ error: "No valid events in request" });
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
eventStore.addBatch(validEvents);
|
|
1150
|
+
logger.debug(`Ingested ${validEvents.length} client events`);
|
|
1151
|
+
res.status(202).json({ accepted: validEvents.length });
|
|
1152
|
+
});
|
|
1153
|
+
return router;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// libs/plugins/analytics/src/lib/adapters/memory-adapter.ts
|
|
1157
|
+
var MemoryAnalyticsAdapter = class {
|
|
1158
|
+
constructor() {
|
|
1159
|
+
this.events = [];
|
|
1160
|
+
}
|
|
1161
|
+
async store(events) {
|
|
1162
|
+
this.events.push(...events);
|
|
1163
|
+
}
|
|
1164
|
+
async query(options = {}) {
|
|
1165
|
+
let filtered = [...this.events];
|
|
1166
|
+
if (options.category) {
|
|
1167
|
+
filtered = filtered.filter((e) => e.category === options.category);
|
|
1168
|
+
}
|
|
1169
|
+
if (options.name) {
|
|
1170
|
+
filtered = filtered.filter((e) => e.name === options.name);
|
|
1171
|
+
}
|
|
1172
|
+
if (options.collection) {
|
|
1173
|
+
filtered = filtered.filter((e) => e.context.collection === options.collection);
|
|
1174
|
+
}
|
|
1175
|
+
if (options.search) {
|
|
1176
|
+
const term = options.search.toLowerCase();
|
|
1177
|
+
filtered = filtered.filter(
|
|
1178
|
+
(e) => e.name.toLowerCase().includes(term) || e.context.url && e.context.url.toLowerCase().includes(term) || e.context.collection && e.context.collection.toLowerCase().includes(term)
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
if (options.from) {
|
|
1182
|
+
filtered = filtered.filter((e) => e.timestamp >= options.from);
|
|
1183
|
+
}
|
|
1184
|
+
if (options.to) {
|
|
1185
|
+
filtered = filtered.filter((e) => e.timestamp <= options.to);
|
|
1186
|
+
}
|
|
1187
|
+
const total = filtered.length;
|
|
1188
|
+
const limit = options.limit ?? 50;
|
|
1189
|
+
const page = options.page ?? 1;
|
|
1190
|
+
const start = (page - 1) * limit;
|
|
1191
|
+
const paged = filtered.slice(start, start + limit);
|
|
1192
|
+
return { events: paged, total, page, limit };
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// libs/plugins/analytics/src/lib/adapters/postgres-adapter.ts
|
|
1197
|
+
function postgresAnalyticsAdapter(options) {
|
|
1198
|
+
const logger = createLogger("Analytics:Postgres");
|
|
1199
|
+
const tableName = options.tableName ?? "_momentum_analytics";
|
|
1200
|
+
let pool = null;
|
|
1201
|
+
async function getPool() {
|
|
1202
|
+
if (!pool) {
|
|
1203
|
+
const { Pool } = await import("pg");
|
|
1204
|
+
pool = new Pool({
|
|
1205
|
+
connectionString: options.connectionString,
|
|
1206
|
+
max: options.poolSize ?? 5
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
return pool;
|
|
1210
|
+
}
|
|
1211
|
+
const adapter = {
|
|
1212
|
+
async initialize() {
|
|
1213
|
+
const p = await getPool();
|
|
1214
|
+
await p.query(`
|
|
1215
|
+
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
1216
|
+
id TEXT PRIMARY KEY,
|
|
1217
|
+
category TEXT NOT NULL,
|
|
1218
|
+
name TEXT NOT NULL,
|
|
1219
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
1220
|
+
session_id TEXT,
|
|
1221
|
+
user_id TEXT,
|
|
1222
|
+
visitor_id TEXT,
|
|
1223
|
+
properties JSONB NOT NULL DEFAULT '{}',
|
|
1224
|
+
context JSONB NOT NULL DEFAULT '{}'
|
|
1225
|
+
)
|
|
1226
|
+
`);
|
|
1227
|
+
await p.query(`
|
|
1228
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_timestamp ON ${tableName} (timestamp DESC)
|
|
1229
|
+
`);
|
|
1230
|
+
await p.query(`
|
|
1231
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_category ON ${tableName} (category)
|
|
1232
|
+
`);
|
|
1233
|
+
await p.query(`
|
|
1234
|
+
CREATE INDEX IF NOT EXISTS idx_${tableName}_name ON ${tableName} (name)
|
|
1235
|
+
`);
|
|
1236
|
+
logger.info(`Table "${tableName}" initialized`);
|
|
1237
|
+
},
|
|
1238
|
+
async store(events) {
|
|
1239
|
+
if (events.length === 0)
|
|
1240
|
+
return;
|
|
1241
|
+
const p = await getPool();
|
|
1242
|
+
const rows = [];
|
|
1243
|
+
const values = [];
|
|
1244
|
+
for (let i = 0; i < events.length; i++) {
|
|
1245
|
+
const e = events[i];
|
|
1246
|
+
const offset = i * 9;
|
|
1247
|
+
rows.push(
|
|
1248
|
+
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9})`
|
|
1249
|
+
);
|
|
1250
|
+
values.push(
|
|
1251
|
+
e.id,
|
|
1252
|
+
e.category,
|
|
1253
|
+
e.name,
|
|
1254
|
+
e.timestamp,
|
|
1255
|
+
e.sessionId ?? null,
|
|
1256
|
+
e.userId ?? null,
|
|
1257
|
+
e.visitorId ?? null,
|
|
1258
|
+
JSON.stringify(e.properties),
|
|
1259
|
+
JSON.stringify(e.context)
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
const sql = `
|
|
1263
|
+
INSERT INTO ${tableName} (id, category, name, timestamp, session_id, user_id, visitor_id, properties, context)
|
|
1264
|
+
VALUES ${rows.join(", ")}
|
|
1265
|
+
ON CONFLICT (id) DO NOTHING
|
|
1266
|
+
`;
|
|
1267
|
+
await p.query(sql, values);
|
|
1268
|
+
},
|
|
1269
|
+
async query(queryOptions = {}) {
|
|
1270
|
+
const p = await getPool();
|
|
1271
|
+
const conditions = [];
|
|
1272
|
+
const params = [];
|
|
1273
|
+
let paramIdx = 1;
|
|
1274
|
+
if (queryOptions.category) {
|
|
1275
|
+
conditions.push(`category = $${paramIdx++}`);
|
|
1276
|
+
params.push(queryOptions.category);
|
|
1277
|
+
}
|
|
1278
|
+
if (queryOptions.name) {
|
|
1279
|
+
conditions.push(`name = $${paramIdx++}`);
|
|
1280
|
+
params.push(queryOptions.name);
|
|
1281
|
+
}
|
|
1282
|
+
if (queryOptions.collection) {
|
|
1283
|
+
conditions.push(`context->>'collection' = $${paramIdx++}`);
|
|
1284
|
+
params.push(queryOptions.collection);
|
|
1285
|
+
}
|
|
1286
|
+
if (queryOptions.search) {
|
|
1287
|
+
const searchPattern = `%${queryOptions.search}%`;
|
|
1288
|
+
conditions.push(
|
|
1289
|
+
`(name ILIKE $${paramIdx} OR context->>'url' ILIKE $${paramIdx} OR context->>'collection' ILIKE $${paramIdx})`
|
|
1290
|
+
);
|
|
1291
|
+
params.push(searchPattern);
|
|
1292
|
+
paramIdx++;
|
|
1293
|
+
}
|
|
1294
|
+
if (queryOptions.from) {
|
|
1295
|
+
conditions.push(`timestamp >= $${paramIdx++}`);
|
|
1296
|
+
params.push(queryOptions.from);
|
|
1297
|
+
}
|
|
1298
|
+
if (queryOptions.to) {
|
|
1299
|
+
conditions.push(`timestamp <= $${paramIdx++}`);
|
|
1300
|
+
params.push(queryOptions.to);
|
|
1301
|
+
}
|
|
1302
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1303
|
+
const limit = Math.min(queryOptions.limit ?? 50, 1e4);
|
|
1304
|
+
const page = queryOptions.page ?? 1;
|
|
1305
|
+
const offset = (page - 1) * limit;
|
|
1306
|
+
const countResult = await p.query(
|
|
1307
|
+
`SELECT COUNT(*)::int AS count FROM ${tableName} ${where}`,
|
|
1308
|
+
params
|
|
1309
|
+
);
|
|
1310
|
+
const total = countResult.rows[0].count;
|
|
1311
|
+
const dataResult = await p.query(
|
|
1312
|
+
`SELECT * FROM ${tableName} ${where} ORDER BY timestamp DESC LIMIT ${limit} OFFSET ${offset}`,
|
|
1313
|
+
params
|
|
1314
|
+
);
|
|
1315
|
+
const events = dataResult.rows.map(rowToEvent);
|
|
1316
|
+
return { events, total, page, limit };
|
|
1317
|
+
},
|
|
1318
|
+
async shutdown() {
|
|
1319
|
+
if (pool) {
|
|
1320
|
+
await pool.end();
|
|
1321
|
+
pool = null;
|
|
1322
|
+
logger.info("Connection pool closed");
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
return adapter;
|
|
1327
|
+
}
|
|
1328
|
+
function rowToEvent(row2) {
|
|
1329
|
+
return {
|
|
1330
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg row fields
|
|
1331
|
+
id: row2["id"],
|
|
1332
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg row fields
|
|
1333
|
+
category: row2["category"],
|
|
1334
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg row fields
|
|
1335
|
+
name: row2["name"],
|
|
1336
|
+
timestamp: row2["timestamp"] instanceof Date ? row2["timestamp"].toISOString() : String(row2["timestamp"]),
|
|
1337
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg row fields
|
|
1338
|
+
sessionId: row2["session_id"] ?? void 0,
|
|
1339
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg row fields
|
|
1340
|
+
userId: row2["user_id"] ?? void 0,
|
|
1341
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg row fields
|
|
1342
|
+
visitorId: row2["visitor_id"] ?? void 0,
|
|
1343
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg JSONB auto-parses
|
|
1344
|
+
properties: row2["properties"] ?? {},
|
|
1345
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- pg JSONB auto-parses
|
|
1346
|
+
context: row2["context"] ?? { source: "server" }
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// libs/plugins/analytics/src/lib/client/tracker.ts
|
|
1351
|
+
function getVisitorId() {
|
|
1352
|
+
if (typeof localStorage === "undefined")
|
|
1353
|
+
return "server";
|
|
1354
|
+
let visitorId = localStorage.getItem("_m_vid");
|
|
1355
|
+
if (!visitorId) {
|
|
1356
|
+
visitorId = generateId();
|
|
1357
|
+
localStorage.setItem("_m_vid", visitorId);
|
|
1358
|
+
}
|
|
1359
|
+
return visitorId;
|
|
1360
|
+
}
|
|
1361
|
+
function getSessionId() {
|
|
1362
|
+
if (typeof sessionStorage === "undefined")
|
|
1363
|
+
return "server";
|
|
1364
|
+
let sessionId = sessionStorage.getItem("_m_sid");
|
|
1365
|
+
if (!sessionId) {
|
|
1366
|
+
sessionId = generateId();
|
|
1367
|
+
sessionStorage.setItem("_m_sid", sessionId);
|
|
1368
|
+
}
|
|
1369
|
+
return sessionId;
|
|
1370
|
+
}
|
|
1371
|
+
function generateId() {
|
|
1372
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
1373
|
+
}
|
|
1374
|
+
function onDomReady(fn) {
|
|
1375
|
+
if (typeof document === "undefined")
|
|
1376
|
+
return;
|
|
1377
|
+
if (document.readyState === "loading") {
|
|
1378
|
+
document.addEventListener("DOMContentLoaded", fn, { once: true });
|
|
1379
|
+
} else {
|
|
1380
|
+
fn();
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function createTracker(config = {}) {
|
|
1384
|
+
const endpoint = config.endpoint ?? "/api/analytics/collect";
|
|
1385
|
+
const flushInterval = config.flushInterval ?? 5e3;
|
|
1386
|
+
const buffer = [];
|
|
1387
|
+
let userId;
|
|
1388
|
+
let _timer = null;
|
|
1389
|
+
const cleanups = [];
|
|
1390
|
+
const visitorId = getVisitorId();
|
|
1391
|
+
const sessionId = getSessionId();
|
|
1392
|
+
function addEvent(event) {
|
|
1393
|
+
buffer.push({
|
|
1394
|
+
...event,
|
|
1395
|
+
sessionId,
|
|
1396
|
+
visitorId
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
function flush() {
|
|
1400
|
+
if (buffer.length === 0)
|
|
1401
|
+
return;
|
|
1402
|
+
const events = buffer.splice(0, buffer.length).map((e) => ({
|
|
1403
|
+
...e,
|
|
1404
|
+
userId
|
|
1405
|
+
}));
|
|
1406
|
+
const body = JSON.stringify({ events });
|
|
1407
|
+
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
|
1408
|
+
const blob = new Blob([body], { type: "application/json" });
|
|
1409
|
+
navigator.sendBeacon(endpoint, blob);
|
|
1410
|
+
} else if (typeof fetch !== "undefined") {
|
|
1411
|
+
void fetch(endpoint, {
|
|
1412
|
+
method: "POST",
|
|
1413
|
+
headers: { "Content-Type": "application/json" },
|
|
1414
|
+
body,
|
|
1415
|
+
keepalive: true
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if (typeof setInterval !== "undefined") {
|
|
1420
|
+
_timer = setInterval(flush, flushInterval);
|
|
1421
|
+
cleanups.push(() => {
|
|
1422
|
+
if (_timer)
|
|
1423
|
+
clearInterval(_timer);
|
|
1424
|
+
_timer = null;
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
if (typeof addEventListener !== "undefined") {
|
|
1428
|
+
addEventListener("beforeunload", flush);
|
|
1429
|
+
cleanups.push(() => removeEventListener("beforeunload", flush));
|
|
1430
|
+
}
|
|
1431
|
+
const tracker = {
|
|
1432
|
+
pageView(properties) {
|
|
1433
|
+
addEvent({
|
|
1434
|
+
name: "page_view",
|
|
1435
|
+
category: "page",
|
|
1436
|
+
properties,
|
|
1437
|
+
context: {
|
|
1438
|
+
url: typeof location !== "undefined" ? location.href : void 0,
|
|
1439
|
+
referrer: typeof document !== "undefined" ? document.referrer : void 0
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
},
|
|
1443
|
+
track(name, properties) {
|
|
1444
|
+
addEvent({
|
|
1445
|
+
name,
|
|
1446
|
+
category: "action",
|
|
1447
|
+
properties,
|
|
1448
|
+
context: {
|
|
1449
|
+
url: typeof location !== "undefined" ? location.href : void 0
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
},
|
|
1453
|
+
identify(id, traits) {
|
|
1454
|
+
userId = id;
|
|
1455
|
+
addEvent({
|
|
1456
|
+
name: "identify",
|
|
1457
|
+
category: "custom",
|
|
1458
|
+
properties: traits
|
|
1459
|
+
});
|
|
1460
|
+
},
|
|
1461
|
+
flush,
|
|
1462
|
+
destroy() {
|
|
1463
|
+
flush();
|
|
1464
|
+
for (const fn of cleanups)
|
|
1465
|
+
fn();
|
|
1466
|
+
cleanups.length = 0;
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
if (config.blockTracking) {
|
|
1470
|
+
onDomReady(() => {
|
|
1471
|
+
void Promise.resolve().then(() => (init_block_tracker(), block_tracker_exports)).then((m) => {
|
|
1472
|
+
const blockCleanup = m.attachBlockTracking(tracker);
|
|
1473
|
+
cleanups.push(blockCleanup);
|
|
1474
|
+
});
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
if (config.trackingRules) {
|
|
1478
|
+
const rulesEndpoint = typeof config.trackingRules === "object" ? config.trackingRules.endpoint : void 0;
|
|
1479
|
+
onDomReady(() => {
|
|
1480
|
+
void Promise.resolve().then(() => (init_rule_engine(), rule_engine_exports)).then((m) => {
|
|
1481
|
+
const engine = m.createRuleEngine(tracker, { endpoint: rulesEndpoint });
|
|
1482
|
+
void engine.start();
|
|
1483
|
+
cleanups.push(() => engine.stop());
|
|
1484
|
+
});
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return tracker;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// libs/plugins/analytics/src/lib/collectors/block-field-injector.ts
|
|
1491
|
+
function createAnalyticsGroupField() {
|
|
1492
|
+
return group("_analytics", {
|
|
1493
|
+
label: "Analytics",
|
|
1494
|
+
admin: { collapsible: true, defaultOpen: false },
|
|
1495
|
+
fields: [
|
|
1496
|
+
checkbox("trackImpressions", { label: "Track Impressions" }),
|
|
1497
|
+
checkbox("trackHover", { label: "Track Hover" })
|
|
1498
|
+
]
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
function hasAnalyticsField(fields) {
|
|
1502
|
+
return fields.some((f) => f.name === "_analytics" && f.type === "group");
|
|
1503
|
+
}
|
|
1504
|
+
function hasBlocks(field) {
|
|
1505
|
+
return field.type === "blocks" && "blocks" in field;
|
|
1506
|
+
}
|
|
1507
|
+
function hasNestedFields(field) {
|
|
1508
|
+
return "fields" in field && Array.isArray(field.fields);
|
|
1509
|
+
}
|
|
1510
|
+
function hasTabs(field) {
|
|
1511
|
+
return field.type === "tabs" && "tabs" in field;
|
|
1512
|
+
}
|
|
1513
|
+
function injectIntoFields(fields) {
|
|
1514
|
+
for (const field of fields) {
|
|
1515
|
+
if (hasBlocks(field)) {
|
|
1516
|
+
for (const blockConfig of field.blocks) {
|
|
1517
|
+
if (!hasAnalyticsField(blockConfig.fields)) {
|
|
1518
|
+
blockConfig.fields.push(createAnalyticsGroupField());
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if ((field.type === "group" || field.type === "array" || field.type === "collapsible" || field.type === "row") && hasNestedFields(field)) {
|
|
1523
|
+
injectIntoFields(field.fields);
|
|
1524
|
+
}
|
|
1525
|
+
if (hasTabs(field)) {
|
|
1526
|
+
for (const tab of field.tabs) {
|
|
1527
|
+
injectIntoFields(tab.fields);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function injectBlockAnalyticsFields(collections) {
|
|
1533
|
+
for (const collection of collections) {
|
|
1534
|
+
injectIntoFields(collection.fields);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// libs/plugins/analytics/src/lib/analytics-query-handler.ts
|
|
1539
|
+
import { Router } from "express";
|
|
1540
|
+
|
|
1541
|
+
// libs/plugins/analytics/src/lib/analytics-auth.ts
|
|
1542
|
+
function isAuthenticated2(req) {
|
|
1543
|
+
if (!("user" in req))
|
|
1544
|
+
return false;
|
|
1545
|
+
const user = req["user"];
|
|
1546
|
+
return user != null && typeof user === "object" && "id" in user;
|
|
1547
|
+
}
|
|
1548
|
+
function isAdmin(req) {
|
|
1549
|
+
if (!isAuthenticated2(req))
|
|
1550
|
+
return false;
|
|
1551
|
+
if (!("user" in req))
|
|
1552
|
+
return false;
|
|
1553
|
+
const user = req["user"];
|
|
1554
|
+
return user != null && typeof user === "object" && "role" in user && user["role"] === "admin";
|
|
1555
|
+
}
|
|
1556
|
+
function requireAdmin(req, res, next) {
|
|
1557
|
+
if (!isAuthenticated2(req)) {
|
|
1558
|
+
res.status(401).json({ error: "Authentication required" });
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (!isAdmin(req)) {
|
|
1562
|
+
res.status(403).json({ error: "Admin access required" });
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
next();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// libs/plugins/analytics/src/lib/analytics-query-handler.ts
|
|
1569
|
+
var VALID_CATEGORIES2 = ["admin", "api", "content", "page", "action", "custom"];
|
|
1570
|
+
function isValidCategory(value) {
|
|
1571
|
+
return VALID_CATEGORIES2.includes(value);
|
|
1572
|
+
}
|
|
1573
|
+
function topPageEntries(record, limit) {
|
|
1574
|
+
return Object.entries(record).sort(([, a], [, b]) => b - a).slice(0, limit).map(([url, count]) => ({ url, count }));
|
|
1575
|
+
}
|
|
1576
|
+
function topReferrerEntries(record, limit) {
|
|
1577
|
+
return Object.entries(record).sort(([, a], [, b]) => b - a).slice(0, limit).map(([referrer, count]) => ({ referrer, count }));
|
|
1578
|
+
}
|
|
1579
|
+
function createAnalyticsQueryRouter(eventStore, adapter) {
|
|
1580
|
+
const router = Router();
|
|
1581
|
+
router.get("/query", requireAdmin, async (req, res) => {
|
|
1582
|
+
try {
|
|
1583
|
+
await eventStore.flush();
|
|
1584
|
+
if (!adapter.query) {
|
|
1585
|
+
res.status(501).json({ error: "Adapter does not support queries" });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const options = {};
|
|
1589
|
+
if (req.query["category"] && typeof req.query["category"] === "string") {
|
|
1590
|
+
if (isValidCategory(req.query["category"])) {
|
|
1591
|
+
options.category = req.query["category"];
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
if (req.query["name"] && typeof req.query["name"] === "string") {
|
|
1595
|
+
options.name = req.query["name"];
|
|
1596
|
+
}
|
|
1597
|
+
if (req.query["collection"] && typeof req.query["collection"] === "string") {
|
|
1598
|
+
options.collection = req.query["collection"];
|
|
1599
|
+
}
|
|
1600
|
+
if (req.query["search"] && typeof req.query["search"] === "string") {
|
|
1601
|
+
options.search = req.query["search"];
|
|
1602
|
+
}
|
|
1603
|
+
if (req.query["from"] && typeof req.query["from"] === "string") {
|
|
1604
|
+
options.from = req.query["from"];
|
|
1605
|
+
}
|
|
1606
|
+
if (req.query["to"] && typeof req.query["to"] === "string") {
|
|
1607
|
+
options.to = req.query["to"];
|
|
1608
|
+
}
|
|
1609
|
+
if (req.query["limit"] && typeof req.query["limit"] === "string") {
|
|
1610
|
+
const limit = parseInt(req.query["limit"], 10);
|
|
1611
|
+
if (!isNaN(limit) && limit > 0 && limit <= 200) {
|
|
1612
|
+
options.limit = limit;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (req.query["page"] && typeof req.query["page"] === "string") {
|
|
1616
|
+
const page = parseInt(req.query["page"], 10);
|
|
1617
|
+
if (!isNaN(page) && page > 0) {
|
|
1618
|
+
options.page = page;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const result = await adapter.query(options);
|
|
1622
|
+
res.json(result);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
console.error("Analytics query failed:", error);
|
|
1625
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
router.get("/summary", requireAdmin, async (req, res) => {
|
|
1629
|
+
try {
|
|
1630
|
+
await eventStore.flush();
|
|
1631
|
+
if (!adapter.query) {
|
|
1632
|
+
res.status(501).json({ error: "Adapter does not support queries" });
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const queryOptions = { limit: 1e4 };
|
|
1636
|
+
if (req.query["from"] && typeof req.query["from"] === "string") {
|
|
1637
|
+
queryOptions.from = req.query["from"];
|
|
1638
|
+
}
|
|
1639
|
+
if (req.query["to"] && typeof req.query["to"] === "string") {
|
|
1640
|
+
queryOptions.to = req.query["to"];
|
|
1641
|
+
}
|
|
1642
|
+
const allEvents = await adapter.query(queryOptions);
|
|
1643
|
+
const events = allEvents.events;
|
|
1644
|
+
const byCategory = {};
|
|
1645
|
+
const byCollection = {};
|
|
1646
|
+
const contentOps = { created: 0, updated: 0, deleted: 0 };
|
|
1647
|
+
let apiRequests = 0;
|
|
1648
|
+
let totalDuration = 0;
|
|
1649
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
1650
|
+
const visitors = /* @__PURE__ */ new Set();
|
|
1651
|
+
const pageCounts = {};
|
|
1652
|
+
const referrerCounts = {};
|
|
1653
|
+
const deviceCounts = {};
|
|
1654
|
+
const browserCounts = {};
|
|
1655
|
+
for (const event of events) {
|
|
1656
|
+
byCategory[event.category] = (byCategory[event.category] ?? 0) + 1;
|
|
1657
|
+
if (event.context.collection) {
|
|
1658
|
+
byCollection[event.context.collection] = (byCollection[event.context.collection] ?? 0) + 1;
|
|
1659
|
+
}
|
|
1660
|
+
if (event.category === "content") {
|
|
1661
|
+
if (event.name === "content_created")
|
|
1662
|
+
contentOps.created++;
|
|
1663
|
+
else if (event.name === "content_updated")
|
|
1664
|
+
contentOps.updated++;
|
|
1665
|
+
else if (event.name === "content_deleted")
|
|
1666
|
+
contentOps.deleted++;
|
|
1667
|
+
}
|
|
1668
|
+
if (event.category === "api") {
|
|
1669
|
+
apiRequests++;
|
|
1670
|
+
if (event.context.duration != null) {
|
|
1671
|
+
totalDuration += event.context.duration;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (event.sessionId)
|
|
1675
|
+
sessions.add(event.sessionId);
|
|
1676
|
+
if (event.visitorId)
|
|
1677
|
+
visitors.add(event.visitorId);
|
|
1678
|
+
if (event.context.url) {
|
|
1679
|
+
pageCounts[event.context.url] = (pageCounts[event.context.url] ?? 0) + 1;
|
|
1680
|
+
}
|
|
1681
|
+
if (event.context.referrer) {
|
|
1682
|
+
referrerCounts[event.context.referrer] = (referrerCounts[event.context.referrer] ?? 0) + 1;
|
|
1683
|
+
}
|
|
1684
|
+
if (event.context.device) {
|
|
1685
|
+
deviceCounts[event.context.device] = (deviceCounts[event.context.device] ?? 0) + 1;
|
|
1686
|
+
}
|
|
1687
|
+
if (event.context.browser) {
|
|
1688
|
+
browserCounts[event.context.browser] = (browserCounts[event.context.browser] ?? 0) + 1;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
const summary = {
|
|
1692
|
+
totalEvents: allEvents.total,
|
|
1693
|
+
byCategory,
|
|
1694
|
+
byCollection,
|
|
1695
|
+
contentOperations: contentOps,
|
|
1696
|
+
apiMetrics: {
|
|
1697
|
+
totalRequests: apiRequests,
|
|
1698
|
+
avgDuration: apiRequests > 0 ? Math.round(totalDuration / apiRequests) : 0
|
|
1699
|
+
},
|
|
1700
|
+
activeSessions: sessions.size,
|
|
1701
|
+
activeVisitors: visitors.size,
|
|
1702
|
+
topPages: topPageEntries(pageCounts, 10),
|
|
1703
|
+
topReferrers: topReferrerEntries(referrerCounts, 10),
|
|
1704
|
+
deviceBreakdown: deviceCounts,
|
|
1705
|
+
browserBreakdown: browserCounts
|
|
1706
|
+
};
|
|
1707
|
+
res.json(summary);
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
console.error("Analytics summary failed:", error);
|
|
1710
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
return router;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// libs/plugins/analytics/src/lib/content-performance/content-performance-handler.ts
|
|
1717
|
+
import { Router as Router2 } from "express";
|
|
1718
|
+
function matchesDocumentUrl(eventUrl, documentPath) {
|
|
1719
|
+
if (!eventUrl)
|
|
1720
|
+
return false;
|
|
1721
|
+
let pathname;
|
|
1722
|
+
try {
|
|
1723
|
+
pathname = new URL(eventUrl).pathname;
|
|
1724
|
+
} catch {
|
|
1725
|
+
pathname = eventUrl;
|
|
1726
|
+
}
|
|
1727
|
+
return pathname === documentPath || pathname === `${documentPath}/`;
|
|
1728
|
+
}
|
|
1729
|
+
function createContentPerformanceRouter(adapter) {
|
|
1730
|
+
const router = Router2();
|
|
1731
|
+
router.get("/content-performance", requireAdmin, async (req, res) => {
|
|
1732
|
+
try {
|
|
1733
|
+
const query = req.query;
|
|
1734
|
+
const collection = typeof query["collection"] === "string" ? query["collection"] : void 0;
|
|
1735
|
+
const documentId = typeof query["documentId"] === "string" ? query["documentId"] : void 0;
|
|
1736
|
+
const from = typeof query["from"] === "string" ? query["from"] : void 0;
|
|
1737
|
+
const to = typeof query["to"] === "string" ? query["to"] : void 0;
|
|
1738
|
+
if (!collection || !documentId) {
|
|
1739
|
+
res.status(400).json({ error: "collection and documentId are required" });
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
if (!adapter.query) {
|
|
1743
|
+
res.status(501).json({ error: "Analytics adapter does not support queries" });
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const documentPath = `/${collection}/${documentId}`;
|
|
1747
|
+
const pageViewResult = await adapter.query({
|
|
1748
|
+
category: "page",
|
|
1749
|
+
name: "page_view",
|
|
1750
|
+
search: documentPath,
|
|
1751
|
+
from,
|
|
1752
|
+
to,
|
|
1753
|
+
limit: 1e3
|
|
1754
|
+
});
|
|
1755
|
+
const pageViewEvents = pageViewResult.events.filter(
|
|
1756
|
+
(e) => matchesDocumentUrl(e.context.url, documentPath)
|
|
1757
|
+
);
|
|
1758
|
+
const visitorSet = /* @__PURE__ */ new Set();
|
|
1759
|
+
const referrerMap = /* @__PURE__ */ new Map();
|
|
1760
|
+
for (const event of pageViewEvents) {
|
|
1761
|
+
const identifier = event.visitorId ?? event.sessionId;
|
|
1762
|
+
if (identifier)
|
|
1763
|
+
visitorSet.add(identifier);
|
|
1764
|
+
const referrer = event.context.referrer;
|
|
1765
|
+
if (referrer) {
|
|
1766
|
+
referrerMap.set(referrer, (referrerMap.get(referrer) ?? 0) + 1);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
let blockEngagement;
|
|
1770
|
+
try {
|
|
1771
|
+
const [impressionResult, hoverResult] = await Promise.all([
|
|
1772
|
+
adapter.query({
|
|
1773
|
+
name: "block_impression",
|
|
1774
|
+
search: documentPath,
|
|
1775
|
+
from,
|
|
1776
|
+
to,
|
|
1777
|
+
limit: 1e3
|
|
1778
|
+
}),
|
|
1779
|
+
adapter.query({
|
|
1780
|
+
name: "block_hover",
|
|
1781
|
+
search: documentPath,
|
|
1782
|
+
from,
|
|
1783
|
+
to,
|
|
1784
|
+
limit: 1e3
|
|
1785
|
+
})
|
|
1786
|
+
]);
|
|
1787
|
+
const impressionEvents = impressionResult.events.filter(
|
|
1788
|
+
(e) => matchesDocumentUrl(e.context.url, documentPath)
|
|
1789
|
+
);
|
|
1790
|
+
const hoverEvents = hoverResult.events.filter(
|
|
1791
|
+
(e) => matchesDocumentUrl(e.context.url, documentPath)
|
|
1792
|
+
);
|
|
1793
|
+
const impressionMap = /* @__PURE__ */ new Map();
|
|
1794
|
+
for (const event of impressionEvents) {
|
|
1795
|
+
const bt = String(event.properties["blockType"] ?? "unknown");
|
|
1796
|
+
impressionMap.set(bt, (impressionMap.get(bt) ?? 0) + 1);
|
|
1797
|
+
}
|
|
1798
|
+
const hoverMap = /* @__PURE__ */ new Map();
|
|
1799
|
+
for (const event of hoverEvents) {
|
|
1800
|
+
const bt = String(event.properties["blockType"] ?? "unknown");
|
|
1801
|
+
hoverMap.set(bt, (hoverMap.get(bt) ?? 0) + 1);
|
|
1802
|
+
}
|
|
1803
|
+
const allTypes = /* @__PURE__ */ new Set([...impressionMap.keys(), ...hoverMap.keys()]);
|
|
1804
|
+
if (allTypes.size > 0) {
|
|
1805
|
+
blockEngagement = [];
|
|
1806
|
+
for (const blockType of allTypes) {
|
|
1807
|
+
blockEngagement.push({
|
|
1808
|
+
blockType,
|
|
1809
|
+
impressions: impressionMap.get(blockType) ?? 0,
|
|
1810
|
+
hovers: hoverMap.get(blockType) ?? 0
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
blockEngagement.sort((a, b) => b.impressions - a.impressions);
|
|
1814
|
+
}
|
|
1815
|
+
} catch {
|
|
1816
|
+
}
|
|
1817
|
+
const topReferrers = [...referrerMap.entries()].map(([referrer, count]) => ({ referrer, count })).sort((a, b) => b.count - a.count).slice(0, 10);
|
|
1818
|
+
const data = {
|
|
1819
|
+
pageViews: pageViewEvents.length,
|
|
1820
|
+
uniqueVisitors: visitorSet.size,
|
|
1821
|
+
topReferrers,
|
|
1822
|
+
blockEngagement
|
|
1823
|
+
};
|
|
1824
|
+
res.json(data);
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
console.error("Content performance query failed:", err);
|
|
1827
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
return router;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// libs/plugins/analytics/src/lib/tracking-rules/tracking-rules-endpoint.ts
|
|
1834
|
+
init_type_guards();
|
|
1835
|
+
init_selector_security();
|
|
1836
|
+
import { Router as Router3 } from "express";
|
|
1837
|
+
function isFindable(val) {
|
|
1838
|
+
return val != null && typeof val === "object" && "find" in val;
|
|
1839
|
+
}
|
|
1840
|
+
var BLOCKED_EXTRACT_ATTRIBUTES = /* @__PURE__ */ new Set(["value", "password", "autocomplete", "autofill"]);
|
|
1841
|
+
var MAX_EXTRACT_VALUE_LENGTH = 200;
|
|
1842
|
+
function sanitizeExtractProperties(raw) {
|
|
1843
|
+
const sanitized = [];
|
|
1844
|
+
for (const entry of raw) {
|
|
1845
|
+
if (!isRecord(entry))
|
|
1846
|
+
continue;
|
|
1847
|
+
const source = typeof entry["source"] === "string" ? entry["source"] : "";
|
|
1848
|
+
const attr = typeof entry["attribute"] === "string" ? entry["attribute"] : "";
|
|
1849
|
+
if (source === "attribute" && BLOCKED_EXTRACT_ATTRIBUTES.has(attr.toLowerCase())) {
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
sanitized.push({ ...entry, maxLength: MAX_EXTRACT_VALUE_LENGTH });
|
|
1853
|
+
}
|
|
1854
|
+
return sanitized.length > 0 ? sanitized : void 0;
|
|
1855
|
+
}
|
|
1856
|
+
function toClientRule(doc) {
|
|
1857
|
+
if (!isRecord(doc))
|
|
1858
|
+
return null;
|
|
1859
|
+
if (typeof doc["name"] !== "string" || typeof doc["selector"] !== "string")
|
|
1860
|
+
return null;
|
|
1861
|
+
if (isSelectorBlocked(doc["selector"]))
|
|
1862
|
+
return null;
|
|
1863
|
+
return {
|
|
1864
|
+
name: doc["name"],
|
|
1865
|
+
selector: doc["selector"],
|
|
1866
|
+
eventType: typeof doc["eventType"] === "string" ? doc["eventType"] : "click",
|
|
1867
|
+
eventName: typeof doc["eventName"] === "string" ? doc["eventName"] : "",
|
|
1868
|
+
urlPattern: typeof doc["urlPattern"] === "string" ? doc["urlPattern"] : "*",
|
|
1869
|
+
properties: isRecord(doc["properties"]) ? doc["properties"] : {},
|
|
1870
|
+
extractProperties: Array.isArray(doc["extractProperties"]) ? sanitizeExtractProperties(doc["extractProperties"]) : void 0,
|
|
1871
|
+
active: doc["active"] === true,
|
|
1872
|
+
rateLimit: typeof doc["rateLimit"] === "number" ? doc["rateLimit"] : void 0
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
function createTrackingRulesRouter(getApi, options = {}) {
|
|
1876
|
+
const router = Router3();
|
|
1877
|
+
const cacheTtl = options.cacheTtl ?? 6e4;
|
|
1878
|
+
let cachedRules = null;
|
|
1879
|
+
let cacheTimestamp = 0;
|
|
1880
|
+
function invalidateCache() {
|
|
1881
|
+
cachedRules = null;
|
|
1882
|
+
cacheTimestamp = 0;
|
|
1883
|
+
}
|
|
1884
|
+
router.get("/tracking-rules", async (_req, res) => {
|
|
1885
|
+
try {
|
|
1886
|
+
const now = Date.now();
|
|
1887
|
+
if (cachedRules && now - cacheTimestamp < cacheTtl) {
|
|
1888
|
+
res.json({ rules: cachedRules });
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const api = getApi();
|
|
1892
|
+
if (!api) {
|
|
1893
|
+
res.json({ rules: [] });
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
const ops = api.collection("tracking-rules");
|
|
1897
|
+
if (!isFindable(ops)) {
|
|
1898
|
+
res.json({ rules: [] });
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const result = await ops.find({
|
|
1902
|
+
where: { active: { equals: true } },
|
|
1903
|
+
limit: 500
|
|
1904
|
+
});
|
|
1905
|
+
const docs = Array.isArray(result.docs) ? result.docs : [];
|
|
1906
|
+
const clientRules = docs.map(toClientRule).filter((rule) => rule != null);
|
|
1907
|
+
cachedRules = clientRules;
|
|
1908
|
+
cacheTimestamp = now;
|
|
1909
|
+
res.json({ rules: clientRules });
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
console.error("Tracking rules query failed:", err);
|
|
1912
|
+
res.status(500).json({ error: "Internal server error" });
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
return { router, invalidateCache };
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// libs/plugins/analytics/src/lib/tracking-rules/tracking-rules-collection.ts
|
|
1919
|
+
init_selector_security();
|
|
1920
|
+
var TrackingRules = defineCollection({
|
|
1921
|
+
slug: "tracking-rules",
|
|
1922
|
+
labels: {
|
|
1923
|
+
singular: "Tracking Rule",
|
|
1924
|
+
plural: "Tracking Rules"
|
|
1925
|
+
},
|
|
1926
|
+
admin: {
|
|
1927
|
+
useAsTitle: "name",
|
|
1928
|
+
group: "Analytics",
|
|
1929
|
+
defaultColumns: ["name", "selector", "eventType", "urlPattern", "active"]
|
|
1930
|
+
},
|
|
1931
|
+
fields: [
|
|
1932
|
+
text("name", { required: true, label: "Rule Name" }),
|
|
1933
|
+
text("selector", {
|
|
1934
|
+
required: true,
|
|
1935
|
+
label: "CSS Selector",
|
|
1936
|
+
description: 'CSS selector to match elements (e.g., ".cta-button", "#signup-form"). Selectors targeting password, hidden, or credit card fields are blocked.',
|
|
1937
|
+
validate: (value) => {
|
|
1938
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
1939
|
+
return "CSS Selector is required";
|
|
1940
|
+
}
|
|
1941
|
+
if (isSelectorBlocked(value)) {
|
|
1942
|
+
return "Selectors targeting password, hidden, or credit card fields are not allowed";
|
|
1943
|
+
}
|
|
1944
|
+
return true;
|
|
1945
|
+
}
|
|
1946
|
+
}),
|
|
1947
|
+
select("eventType", {
|
|
1948
|
+
required: true,
|
|
1949
|
+
label: "Event Type",
|
|
1950
|
+
options: [
|
|
1951
|
+
{ label: "Click", value: "click" },
|
|
1952
|
+
{ label: "Submit", value: "submit" },
|
|
1953
|
+
{ label: "Scroll Into View", value: "scroll-into-view" },
|
|
1954
|
+
{ label: "Hover", value: "hover" },
|
|
1955
|
+
{ label: "Focus", value: "focus" }
|
|
1956
|
+
],
|
|
1957
|
+
defaultValue: "click"
|
|
1958
|
+
}),
|
|
1959
|
+
text("eventName", {
|
|
1960
|
+
required: true,
|
|
1961
|
+
label: "Event Name",
|
|
1962
|
+
description: 'Analytics event name to fire (e.g., "cta_click", "form_submit")'
|
|
1963
|
+
}),
|
|
1964
|
+
text("urlPattern", {
|
|
1965
|
+
required: true,
|
|
1966
|
+
label: "URL Pattern",
|
|
1967
|
+
description: 'URL pattern to match pages. Use * for wildcards (e.g., "/blog/*", "*" for all)',
|
|
1968
|
+
defaultValue: "*"
|
|
1969
|
+
}),
|
|
1970
|
+
json("properties", {
|
|
1971
|
+
label: "Static Properties",
|
|
1972
|
+
description: "Key-value pairs attached to every event"
|
|
1973
|
+
}),
|
|
1974
|
+
json("extractProperties", {
|
|
1975
|
+
label: "Extract Properties",
|
|
1976
|
+
description: 'Extract dynamic values from matched DOM elements. Extraction of "value", "password", and "autocomplete" attributes is blocked. Values are truncated to 200 characters.'
|
|
1977
|
+
}),
|
|
1978
|
+
checkbox("active", { label: "Active", defaultValue: true }),
|
|
1979
|
+
number("rateLimit", {
|
|
1980
|
+
label: "Rate Limit",
|
|
1981
|
+
description: "Max events per minute per visitor (leave empty for unlimited)",
|
|
1982
|
+
admin: { position: "sidebar" }
|
|
1983
|
+
})
|
|
1984
|
+
],
|
|
1985
|
+
access: {
|
|
1986
|
+
read: () => true,
|
|
1987
|
+
// Public: client-side rule engine fetches rules without auth
|
|
1988
|
+
create: hasRole("admin"),
|
|
1989
|
+
update: hasRole("admin"),
|
|
1990
|
+
delete: hasRole("admin"),
|
|
1991
|
+
admin: hasRole("admin")
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
// libs/plugins/analytics/src/lib/analytics-plugin.ts
|
|
1996
|
+
function resolveAdminRoutes(dashboardConfig) {
|
|
1997
|
+
if (dashboardConfig === false)
|
|
1998
|
+
return [];
|
|
1999
|
+
const dashboardModule = "./dashboard/analytics-dashboard.page";
|
|
2000
|
+
const defaultLoadComponent = () => import(dashboardModule).then((m) => m["AnalyticsDashboardPage"]);
|
|
2001
|
+
const defaultRoute = {
|
|
2002
|
+
path: "analytics",
|
|
2003
|
+
label: "Analytics",
|
|
2004
|
+
icon: "heroChartBarSquare",
|
|
2005
|
+
loadComponent: defaultLoadComponent,
|
|
2006
|
+
group: "Analytics"
|
|
2007
|
+
};
|
|
2008
|
+
if (dashboardConfig === void 0 || dashboardConfig === true) {
|
|
2009
|
+
return [defaultRoute];
|
|
2010
|
+
}
|
|
2011
|
+
return [
|
|
2012
|
+
{
|
|
2013
|
+
...defaultRoute,
|
|
2014
|
+
loadComponent: dashboardConfig.loadComponent ?? defaultLoadComponent,
|
|
2015
|
+
group: dashboardConfig.group ?? defaultRoute.group
|
|
2016
|
+
}
|
|
2017
|
+
];
|
|
2018
|
+
}
|
|
2019
|
+
function analyticsPlugin(config) {
|
|
2020
|
+
const eventStore = new EventStore({
|
|
2021
|
+
adapter: config.adapter,
|
|
2022
|
+
flushInterval: config.flushInterval,
|
|
2023
|
+
flushBatchSize: config.flushBatchSize
|
|
2024
|
+
});
|
|
2025
|
+
const adminRoutes = resolveAdminRoutes(config.adminDashboard);
|
|
2026
|
+
let momentumApi = null;
|
|
2027
|
+
if (config.contentPerformance !== false && config.adminDashboard !== false) {
|
|
2028
|
+
const contentPerfModule = "./dashboard/content-performance.page";
|
|
2029
|
+
adminRoutes.push({
|
|
2030
|
+
path: "analytics/content",
|
|
2031
|
+
label: "Content Perf.",
|
|
2032
|
+
icon: "heroDocumentText",
|
|
2033
|
+
loadComponent: () => import(contentPerfModule).then((m) => m["ContentPerformancePage"]),
|
|
2034
|
+
group: "Analytics"
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
if (config.trackingRules !== false && config.adminDashboard !== false) {
|
|
2038
|
+
const trackingRulesModule = "./dashboard/tracking-rules.page";
|
|
2039
|
+
adminRoutes.push({
|
|
2040
|
+
path: "analytics/tracking-rules",
|
|
2041
|
+
label: "Tracking Rules",
|
|
2042
|
+
icon: "heroCursorArrowRays",
|
|
2043
|
+
loadComponent: () => import(trackingRulesModule).then((m) => m["TrackingRulesPage"]),
|
|
2044
|
+
group: "Analytics"
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
return {
|
|
2048
|
+
name: "analytics",
|
|
2049
|
+
eventStore,
|
|
2050
|
+
analyticsConfig: config,
|
|
2051
|
+
adminRoutes,
|
|
2052
|
+
modifyCollections(collections) {
|
|
2053
|
+
if (config.enabled !== false && config.blockTracking !== false) {
|
|
2054
|
+
injectBlockAnalyticsFields(collections);
|
|
2055
|
+
}
|
|
2056
|
+
},
|
|
2057
|
+
async onInit({ collections, logger, registerMiddleware }) {
|
|
2058
|
+
if (config.enabled === false) {
|
|
2059
|
+
logger.info("Analytics disabled");
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (config.adapter.initialize) {
|
|
2063
|
+
logger.info("Initializing analytics adapter...");
|
|
2064
|
+
await config.adapter.initialize();
|
|
2065
|
+
}
|
|
2066
|
+
if (config.blockTracking !== false) {
|
|
2067
|
+
injectBlockAnalyticsFields(collections);
|
|
2068
|
+
logger.info("Block analytics fields injected");
|
|
2069
|
+
}
|
|
2070
|
+
if (config.trackCollections !== false) {
|
|
2071
|
+
injectCollectionCollector(collections, (event) => eventStore.add(event), {
|
|
2072
|
+
excludeCollections: config.excludeCollections
|
|
2073
|
+
});
|
|
2074
|
+
logger.info(`Collection tracking enabled for ${collections.length} collections`);
|
|
2075
|
+
}
|
|
2076
|
+
const ingestRouter = createIngestRouter({
|
|
2077
|
+
eventStore,
|
|
2078
|
+
rateLimit: config.ingestRateLimit
|
|
2079
|
+
});
|
|
2080
|
+
registerMiddleware({
|
|
2081
|
+
path: config.ingestPath ?? "/analytics/collect",
|
|
2082
|
+
handler: ingestRouter,
|
|
2083
|
+
position: "before-api"
|
|
2084
|
+
});
|
|
2085
|
+
const queryRouter = createAnalyticsQueryRouter(eventStore, config.adapter);
|
|
2086
|
+
registerMiddleware({
|
|
2087
|
+
path: "/analytics",
|
|
2088
|
+
handler: queryRouter,
|
|
2089
|
+
position: "before-api"
|
|
2090
|
+
});
|
|
2091
|
+
if (config.trackApi !== false) {
|
|
2092
|
+
const apiCollector = createApiCollectorMiddleware((event) => eventStore.add(event));
|
|
2093
|
+
registerMiddleware({
|
|
2094
|
+
path: "/",
|
|
2095
|
+
handler: apiCollector,
|
|
2096
|
+
position: "before-api"
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
if (config.contentPerformance !== false) {
|
|
2100
|
+
const contentPerfRouter = createContentPerformanceRouter(config.adapter);
|
|
2101
|
+
registerMiddleware({
|
|
2102
|
+
path: "/analytics",
|
|
2103
|
+
handler: contentPerfRouter,
|
|
2104
|
+
position: "before-api"
|
|
2105
|
+
});
|
|
2106
|
+
logger.info("Content performance endpoint registered");
|
|
2107
|
+
}
|
|
2108
|
+
if (config.trackingRules !== false) {
|
|
2109
|
+
const cacheTtl = typeof config.trackingRules === "object" ? config.trackingRules.cacheTtl : void 0;
|
|
2110
|
+
const { router: trackingRulesRouter, invalidateCache } = createTrackingRulesRouter(
|
|
2111
|
+
() => momentumApi,
|
|
2112
|
+
cacheTtl != null ? { cacheTtl } : void 0
|
|
2113
|
+
);
|
|
2114
|
+
const rulesWithHooks = {
|
|
2115
|
+
...TrackingRules,
|
|
2116
|
+
hooks: {
|
|
2117
|
+
...TrackingRules.hooks,
|
|
2118
|
+
afterChange: [
|
|
2119
|
+
...TrackingRules.hooks?.afterChange ?? [],
|
|
2120
|
+
() => {
|
|
2121
|
+
invalidateCache();
|
|
2122
|
+
}
|
|
2123
|
+
],
|
|
2124
|
+
afterDelete: [
|
|
2125
|
+
...TrackingRules.hooks?.afterDelete ?? [],
|
|
2126
|
+
() => {
|
|
2127
|
+
invalidateCache();
|
|
2128
|
+
}
|
|
2129
|
+
]
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
collections.push(rulesWithHooks);
|
|
2133
|
+
registerMiddleware({
|
|
2134
|
+
path: "/analytics",
|
|
2135
|
+
handler: trackingRulesRouter,
|
|
2136
|
+
position: "before-api"
|
|
2137
|
+
});
|
|
2138
|
+
logger.info("Tracking rules collection and endpoint registered");
|
|
2139
|
+
}
|
|
2140
|
+
if (adminRoutes.length > 0) {
|
|
2141
|
+
logger.info("Analytics admin dashboard route declared");
|
|
2142
|
+
}
|
|
2143
|
+
logger.info("Analytics plugin initialized");
|
|
2144
|
+
},
|
|
2145
|
+
async onReady({ logger, api }) {
|
|
2146
|
+
if (config.enabled === false)
|
|
2147
|
+
return;
|
|
2148
|
+
momentumApi = api;
|
|
2149
|
+
eventStore.start();
|
|
2150
|
+
logger.info("Analytics event flush timer started");
|
|
2151
|
+
},
|
|
2152
|
+
async onShutdown({ logger }) {
|
|
2153
|
+
logger.info("Shutting down analytics...");
|
|
2154
|
+
await eventStore.shutdown();
|
|
2155
|
+
if (config.adapter.shutdown) {
|
|
2156
|
+
await config.adapter.shutdown();
|
|
2157
|
+
}
|
|
2158
|
+
logger.info("Analytics shut down");
|
|
2159
|
+
}
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// libs/plugins/analytics/src/lib/analytics-middleware.ts
|
|
2164
|
+
function createAnalyticsMiddleware(plugin) {
|
|
2165
|
+
const { eventStore, analyticsConfig } = plugin;
|
|
2166
|
+
const ingestRouter = createIngestRouter({
|
|
2167
|
+
eventStore,
|
|
2168
|
+
rateLimit: analyticsConfig.ingestRateLimit
|
|
2169
|
+
});
|
|
2170
|
+
const apiCollector = createApiCollectorMiddleware((event) => eventStore.add(event));
|
|
2171
|
+
return { ingestRouter, apiCollector };
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// libs/plugins/analytics/src/index.ts
|
|
2175
|
+
init_block_tracker();
|
|
2176
|
+
init_rule_engine();
|
|
2177
|
+
export {
|
|
2178
|
+
EventStore,
|
|
2179
|
+
MemoryAnalyticsAdapter,
|
|
2180
|
+
TrackingRules,
|
|
2181
|
+
analyticsPlugin,
|
|
2182
|
+
attachBlockTracking,
|
|
2183
|
+
createAnalyticsMiddleware,
|
|
2184
|
+
createAnalyticsQueryRouter,
|
|
2185
|
+
createApiCollectorMiddleware,
|
|
2186
|
+
createContentPerformanceRouter,
|
|
2187
|
+
createIngestRouter,
|
|
2188
|
+
createRuleEngine,
|
|
2189
|
+
createTracker,
|
|
2190
|
+
createTrackingRulesRouter,
|
|
2191
|
+
injectBlockAnalyticsFields,
|
|
2192
|
+
injectCollectionCollector,
|
|
2193
|
+
parseUserAgent,
|
|
2194
|
+
postgresAnalyticsAdapter
|
|
2195
|
+
};
|