@momentumcms/plugins-analytics 0.4.0 → 0.4.1

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/index.cjs CHANGED
@@ -409,16 +409,20 @@ __export(src_exports, {
409
409
  TrackingRules: () => TrackingRules,
410
410
  analyticsPlugin: () => analyticsPlugin,
411
411
  attachBlockTracking: () => attachBlockTracking,
412
+ compileContentRoutes: () => compileContentRoutes,
412
413
  createAnalyticsMiddleware: () => createAnalyticsMiddleware,
413
414
  createAnalyticsQueryRouter: () => createAnalyticsQueryRouter,
414
415
  createApiCollectorMiddleware: () => createApiCollectorMiddleware,
415
416
  createContentPerformanceRouter: () => createContentPerformanceRouter,
416
417
  createIngestRouter: () => createIngestRouter,
418
+ createPageViewCollectorMiddleware: () => createPageViewCollectorMiddleware,
417
419
  createRuleEngine: () => createRuleEngine,
418
420
  createTracker: () => createTracker,
419
421
  createTrackingRulesRouter: () => createTrackingRulesRouter,
420
422
  injectBlockAnalyticsFields: () => injectBlockAnalyticsFields,
421
423
  injectCollectionCollector: () => injectCollectionCollector,
424
+ isBot: () => isBot,
425
+ matchContentRoute: () => matchContentRoute,
422
426
  parseUserAgent: () => parseUserAgent,
423
427
  postgresAnalyticsAdapter: () => postgresAnalyticsAdapter
424
428
  });
@@ -876,8 +880,210 @@ function createApiCollectorMiddleware(emitter) {
876
880
  };
877
881
  }
878
882
 
879
- // libs/plugins/analytics/src/lib/ingest-handler.ts
883
+ // libs/plugins/analytics/src/lib/collectors/page-view-collector.ts
880
884
  var import_node_crypto3 = require("node:crypto");
885
+
886
+ // libs/plugins/analytics/src/lib/utils/content-route-matcher.ts
887
+ function escapeRegex(str) {
888
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
889
+ }
890
+ function compileContentRoute(collection, pattern) {
891
+ const segments = pattern.split("/").filter(Boolean);
892
+ const paramNames = [];
893
+ let staticCount = 0;
894
+ const regexParts = segments.map((seg) => {
895
+ if (seg.startsWith(":")) {
896
+ paramNames.push(seg.slice(1));
897
+ return "([^/]+)";
898
+ }
899
+ staticCount++;
900
+ return escapeRegex(seg);
901
+ });
902
+ const regexStr = "^/" + regexParts.join("/") + "/?$";
903
+ return {
904
+ collection,
905
+ pattern,
906
+ regex: new RegExp(regexStr),
907
+ paramNames,
908
+ staticSegments: staticCount
909
+ };
910
+ }
911
+ function compileContentRoutes(routes) {
912
+ const compiled = Object.entries(routes).map(
913
+ ([collection, pattern]) => compileContentRoute(collection, pattern)
914
+ );
915
+ compiled.sort((a, b) => b.staticSegments - a.staticSegments);
916
+ return compiled;
917
+ }
918
+ function matchContentRoute(path, routes) {
919
+ for (const route of routes) {
920
+ const match = route.regex.exec(path);
921
+ if (match) {
922
+ const params = {};
923
+ for (let i = 0; i < route.paramNames.length; i++) {
924
+ params[route.paramNames[i]] = match[i + 1];
925
+ }
926
+ return { collection: route.collection, params };
927
+ }
928
+ }
929
+ return void 0;
930
+ }
931
+
932
+ // libs/plugins/analytics/src/lib/collectors/page-view-collector.ts
933
+ var DEFAULT_EXCLUDE_EXTENSIONS = /* @__PURE__ */ new Set([
934
+ ".js",
935
+ ".css",
936
+ ".ico",
937
+ ".png",
938
+ ".jpg",
939
+ ".jpeg",
940
+ ".gif",
941
+ ".svg",
942
+ ".webp",
943
+ ".avif",
944
+ ".woff",
945
+ ".woff2",
946
+ ".ttf",
947
+ ".eot",
948
+ ".map",
949
+ ".json",
950
+ ".xml",
951
+ ".txt",
952
+ ".mp4",
953
+ ".webm",
954
+ ".ogg",
955
+ ".mp3"
956
+ ]);
957
+ var ALWAYS_EXCLUDED_PREFIXES = [
958
+ "/api/",
959
+ "/admin",
960
+ "/__vite",
961
+ "/@fs/",
962
+ "/@id/",
963
+ "/.analog/",
964
+ "/node_modules/"
965
+ ];
966
+ var ALWAYS_EXCLUDED_EXACT = /* @__PURE__ */ new Set([
967
+ "/api",
968
+ "/favicon.ico",
969
+ "/robots.txt",
970
+ "/sitemap.xml",
971
+ "/sitemap-index.xml",
972
+ "/health",
973
+ "/healthz",
974
+ "/ready",
975
+ "/.well-known/security.txt"
976
+ ]);
977
+ var BOT_PATTERN = /bot|crawl|spider|slurp|bingpreview|mediapartners|facebookexternalhit|linkedinbot|twitterbot|whatsapp|telegrambot|discordbot|applebot|duckduckbot|yandex|baidu|sogou|ia_archiver|semrush|ahref|mj12bot|dotbot|petalbot|bytespider/i;
978
+ function isBot(ua) {
979
+ if (!ua)
980
+ return false;
981
+ return BOT_PATTERN.test(ua);
982
+ }
983
+ function shouldExcludePath(path, excludeExtensions, excludePaths) {
984
+ if (ALWAYS_EXCLUDED_EXACT.has(path))
985
+ return true;
986
+ for (const prefix of ALWAYS_EXCLUDED_PREFIXES) {
987
+ if (path.startsWith(prefix))
988
+ return true;
989
+ }
990
+ for (const pattern of excludePaths) {
991
+ if (path.startsWith(pattern))
992
+ return true;
993
+ }
994
+ const dotIndex = path.lastIndexOf(".");
995
+ if (dotIndex !== -1) {
996
+ const ext = path.slice(dotIndex).toLowerCase();
997
+ if (excludeExtensions.has(ext))
998
+ return true;
999
+ }
1000
+ return false;
1001
+ }
1002
+ function extractUserId(req) {
1003
+ if (!("user" in req))
1004
+ return void 0;
1005
+ const user = req["user"];
1006
+ if (user == null || typeof user !== "object")
1007
+ return void 0;
1008
+ if (!("id" in user))
1009
+ return void 0;
1010
+ const id = user.id;
1011
+ return typeof id === "string" ? id : void 0;
1012
+ }
1013
+ function createPageViewCollectorMiddleware(emitter, options = {}) {
1014
+ const excludeExtensions = options.excludeExtensions ? new Set(options.excludeExtensions) : DEFAULT_EXCLUDE_EXTENSIONS;
1015
+ const excludePaths = options.excludePaths ?? [];
1016
+ const onlySuccessful = options.onlySuccessful !== false;
1017
+ const trackBots = options.trackBots === true;
1018
+ const compiledRoutes = options.contentRoutes ? compileContentRoutes(options.contentRoutes) : void 0;
1019
+ return (req, res, next) => {
1020
+ if (req.method !== "GET") {
1021
+ next();
1022
+ return;
1023
+ }
1024
+ const path = req.path;
1025
+ if (shouldExcludePath(path, excludeExtensions, excludePaths)) {
1026
+ next();
1027
+ return;
1028
+ }
1029
+ const ua = req.headers["user-agent"];
1030
+ if (!trackBots && isBot(ua)) {
1031
+ next();
1032
+ return;
1033
+ }
1034
+ const start = Date.now();
1035
+ res.once("finish", () => {
1036
+ if (onlySuccessful && (res.statusCode < 200 || res.statusCode >= 300)) {
1037
+ return;
1038
+ }
1039
+ const duration = Date.now() - start;
1040
+ const parsed = parseUserAgent(ua);
1041
+ const refHeader = req.headers["referer"] ?? req.headers["referrer"];
1042
+ const referrer = Array.isArray(refHeader) ? refHeader[0] : refHeader;
1043
+ let contentCollection;
1044
+ let contentSlug;
1045
+ if (compiledRoutes) {
1046
+ const routeMatch = matchContentRoute(path, compiledRoutes);
1047
+ if (routeMatch) {
1048
+ contentCollection = routeMatch.collection;
1049
+ contentSlug = routeMatch.params["slug"];
1050
+ }
1051
+ }
1052
+ const event = {
1053
+ id: (0, import_node_crypto3.randomUUID)(),
1054
+ category: "page",
1055
+ name: "page_view",
1056
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1057
+ userId: extractUserId(req),
1058
+ properties: {
1059
+ method: req.method,
1060
+ path,
1061
+ statusCode: res.statusCode,
1062
+ ...contentCollection != null ? { collection: contentCollection } : {},
1063
+ ...contentSlug != null ? { slug: contentSlug } : {}
1064
+ },
1065
+ context: {
1066
+ source: "server",
1067
+ url: req.originalUrl,
1068
+ referrer,
1069
+ userAgent: ua,
1070
+ ip: req.ip ?? req.socket.remoteAddress,
1071
+ device: parsed.device,
1072
+ browser: parsed.browser,
1073
+ os: parsed.os,
1074
+ duration,
1075
+ statusCode: res.statusCode,
1076
+ ...contentCollection != null ? { collection: contentCollection } : {}
1077
+ }
1078
+ };
1079
+ emitter(event);
1080
+ });
1081
+ next();
1082
+ };
1083
+ }
1084
+
1085
+ // libs/plugins/analytics/src/lib/ingest-handler.ts
1086
+ var import_node_crypto4 = require("node:crypto");
881
1087
  var import_express = require("express");
882
1088
 
883
1089
  // libs/core/src/lib/collections/define-collection.ts
@@ -1158,7 +1364,7 @@ function createIngestRouter(options) {
1158
1364
  }
1159
1365
  const partial = raw;
1160
1366
  const event = {
1161
- id: (0, import_node_crypto3.randomUUID)(),
1367
+ id: (0, import_node_crypto4.randomUUID)(),
1162
1368
  category: partial.category ?? "custom",
1163
1369
  name: partial.name ?? "unknown",
1164
1370
  // Server-side timestamp (prevents client clock skew)
@@ -1764,6 +1970,42 @@ function matchesDocumentUrl(eventUrl, documentPath) {
1764
1970
  }
1765
1971
  return pathname === documentPath || pathname === `${documentPath}/`;
1766
1972
  }
1973
+ async function queryEventsByDocument(queryFn, eventName, collection, documentId, documentPath, options) {
1974
+ const queryBase = {
1975
+ ...options.category ? { category: options.category } : {},
1976
+ name: eventName,
1977
+ from: options.from,
1978
+ to: options.to,
1979
+ limit: 1e3
1980
+ };
1981
+ const [serverResult, allResult] = await Promise.all([
1982
+ queryFn({ ...queryBase, collection }),
1983
+ queryFn(queryBase)
1984
+ ]);
1985
+ const seen = /* @__PURE__ */ new Set();
1986
+ const results = [];
1987
+ for (const e of [...serverResult.events, ...allResult.events]) {
1988
+ if (seen.has(e.id))
1989
+ continue;
1990
+ seen.add(e.id);
1991
+ const matchesCollection = e.context.collection === collection || e.properties["collection"] === collection;
1992
+ const matchesSlug = typeof e.properties["slug"] === "string" && e.properties["slug"] === documentId;
1993
+ if (matchesCollection && matchesSlug)
1994
+ results.push(e);
1995
+ }
1996
+ if (results.length > 0)
1997
+ return results;
1998
+ const urlResult = await queryFn({ ...queryBase, search: documentPath });
1999
+ return urlResult.events.filter((e) => matchesDocumentUrl(e.context.url, documentPath));
2000
+ }
2001
+ function countByBlockType(events) {
2002
+ const map = /* @__PURE__ */ new Map();
2003
+ for (const event of events) {
2004
+ const bt = String(event.properties["blockType"] ?? "unknown");
2005
+ map.set(bt, (map.get(bt) ?? 0) + 1);
2006
+ }
2007
+ return map;
2008
+ }
1767
2009
  function createContentPerformanceRouter(adapter) {
1768
2010
  const router = (0, import_express3.Router)();
1769
2011
  router.get("/content-performance", requireAdmin, async (req, res) => {
@@ -1782,16 +2024,15 @@ function createContentPerformanceRouter(adapter) {
1782
2024
  return;
1783
2025
  }
1784
2026
  const documentPath = `/${collection}/${documentId}`;
1785
- const pageViewResult = await adapter.query({
1786
- category: "page",
1787
- name: "page_view",
1788
- search: documentPath,
1789
- from,
1790
- to,
1791
- limit: 1e3
1792
- });
1793
- const pageViewEvents = pageViewResult.events.filter(
1794
- (e) => matchesDocumentUrl(e.context.url, documentPath)
2027
+ const queryFn = adapter.query.bind(adapter);
2028
+ const dateRange = { from, to };
2029
+ const pageViewEvents = await queryEventsByDocument(
2030
+ queryFn,
2031
+ "page_view",
2032
+ collection,
2033
+ documentId,
2034
+ documentPath,
2035
+ { category: "page", ...dateRange }
1795
2036
  );
1796
2037
  const visitorSet = /* @__PURE__ */ new Set();
1797
2038
  const referrerMap = /* @__PURE__ */ new Map();
@@ -1806,38 +2047,26 @@ function createContentPerformanceRouter(adapter) {
1806
2047
  }
1807
2048
  let blockEngagement;
1808
2049
  try {
1809
- const [impressionResult, hoverResult] = await Promise.all([
1810
- adapter.query({
1811
- name: "block_impression",
1812
- search: documentPath,
1813
- from,
1814
- to,
1815
- limit: 1e3
1816
- }),
1817
- adapter.query({
1818
- name: "block_hover",
1819
- search: documentPath,
1820
- from,
1821
- to,
1822
- limit: 1e3
1823
- })
2050
+ const [impressionEvents, hoverEvents] = await Promise.all([
2051
+ queryEventsByDocument(
2052
+ queryFn,
2053
+ "block_impression",
2054
+ collection,
2055
+ documentId,
2056
+ documentPath,
2057
+ dateRange
2058
+ ),
2059
+ queryEventsByDocument(
2060
+ queryFn,
2061
+ "block_hover",
2062
+ collection,
2063
+ documentId,
2064
+ documentPath,
2065
+ dateRange
2066
+ )
1824
2067
  ]);
1825
- const impressionEvents = impressionResult.events.filter(
1826
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1827
- );
1828
- const hoverEvents = hoverResult.events.filter(
1829
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1830
- );
1831
- const impressionMap = /* @__PURE__ */ new Map();
1832
- for (const event of impressionEvents) {
1833
- const bt = String(event.properties["blockType"] ?? "unknown");
1834
- impressionMap.set(bt, (impressionMap.get(bt) ?? 0) + 1);
1835
- }
1836
- const hoverMap = /* @__PURE__ */ new Map();
1837
- for (const event of hoverEvents) {
1838
- const bt = String(event.properties["blockType"] ?? "unknown");
1839
- hoverMap.set(bt, (hoverMap.get(bt) ?? 0) + 1);
1840
- }
2068
+ const impressionMap = countByBlockType(impressionEvents);
2069
+ const hoverMap = countByBlockType(hoverEvents);
1841
2070
  const allTypes = /* @__PURE__ */ new Set([...impressionMap.keys(), ...hoverMap.keys()]);
1842
2071
  if (allTypes.size > 0) {
1843
2072
  blockEngagement = [];
@@ -2145,6 +2374,19 @@ function analyticsPlugin(config) {
2145
2374
  position: "before-api"
2146
2375
  });
2147
2376
  }
2377
+ if (config.trackPageViews !== false) {
2378
+ const pageViewOptions = typeof config.trackPageViews === "object" ? config.trackPageViews : {};
2379
+ const pageViewCollector = createPageViewCollectorMiddleware(
2380
+ (event) => eventStore.add(event),
2381
+ pageViewOptions
2382
+ );
2383
+ registerMiddleware({
2384
+ path: "/",
2385
+ handler: pageViewCollector,
2386
+ position: "root"
2387
+ });
2388
+ logger.info("Page view tracking enabled");
2389
+ }
2148
2390
  if (config.contentPerformance !== false) {
2149
2391
  const contentPerfRouter = createContentPerformanceRouter(config.adapter);
2150
2392
  registerMiddleware({
@@ -2230,16 +2472,20 @@ init_rule_engine();
2230
2472
  TrackingRules,
2231
2473
  analyticsPlugin,
2232
2474
  attachBlockTracking,
2475
+ compileContentRoutes,
2233
2476
  createAnalyticsMiddleware,
2234
2477
  createAnalyticsQueryRouter,
2235
2478
  createApiCollectorMiddleware,
2236
2479
  createContentPerformanceRouter,
2237
2480
  createIngestRouter,
2481
+ createPageViewCollectorMiddleware,
2238
2482
  createRuleEngine,
2239
2483
  createTracker,
2240
2484
  createTrackingRulesRouter,
2241
2485
  injectBlockAnalyticsFields,
2242
2486
  injectCollectionCollector,
2487
+ isBot,
2488
+ matchContentRoute,
2243
2489
  parseUserAgent,
2244
2490
  postgresAnalyticsAdapter
2245
2491
  });