@momentumcms/plugins-analytics 0.3.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
@@ -36,6 +36,10 @@ __export(block_tracker_exports, {
36
36
  attachBlockTracking: () => attachBlockTracking
37
37
  });
38
38
  function attachBlockTracking(tracker, container) {
39
+ if (typeof document === "undefined" || !document.body) {
40
+ return () => {
41
+ };
42
+ }
39
43
  const root = container ?? document.body;
40
44
  const impressionsSeen = /* @__PURE__ */ new Set();
41
45
  const hoverCooldowns = /* @__PURE__ */ new Map();
@@ -405,16 +409,20 @@ __export(src_exports, {
405
409
  TrackingRules: () => TrackingRules,
406
410
  analyticsPlugin: () => analyticsPlugin,
407
411
  attachBlockTracking: () => attachBlockTracking,
412
+ compileContentRoutes: () => compileContentRoutes,
408
413
  createAnalyticsMiddleware: () => createAnalyticsMiddleware,
409
414
  createAnalyticsQueryRouter: () => createAnalyticsQueryRouter,
410
415
  createApiCollectorMiddleware: () => createApiCollectorMiddleware,
411
416
  createContentPerformanceRouter: () => createContentPerformanceRouter,
412
417
  createIngestRouter: () => createIngestRouter,
418
+ createPageViewCollectorMiddleware: () => createPageViewCollectorMiddleware,
413
419
  createRuleEngine: () => createRuleEngine,
414
420
  createTracker: () => createTracker,
415
421
  createTrackingRulesRouter: () => createTrackingRulesRouter,
416
422
  injectBlockAnalyticsFields: () => injectBlockAnalyticsFields,
417
423
  injectCollectionCollector: () => injectCollectionCollector,
424
+ isBot: () => isBot,
425
+ matchContentRoute: () => matchContentRoute,
418
426
  parseUserAgent: () => parseUserAgent,
419
427
  postgresAnalyticsAdapter: () => postgresAnalyticsAdapter
420
428
  });
@@ -872,8 +880,210 @@ function createApiCollectorMiddleware(emitter) {
872
880
  };
873
881
  }
874
882
 
875
- // libs/plugins/analytics/src/lib/ingest-handler.ts
883
+ // libs/plugins/analytics/src/lib/collectors/page-view-collector.ts
876
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");
877
1087
  var import_express = require("express");
878
1088
 
879
1089
  // libs/core/src/lib/collections/define-collection.ts
@@ -1154,7 +1364,7 @@ function createIngestRouter(options) {
1154
1364
  }
1155
1365
  const partial = raw;
1156
1366
  const event = {
1157
- id: (0, import_node_crypto3.randomUUID)(),
1367
+ id: (0, import_node_crypto4.randomUUID)(),
1158
1368
  category: partial.category ?? "custom",
1159
1369
  name: partial.name ?? "unknown",
1160
1370
  // Server-side timestamp (prevents client clock skew)
@@ -1760,6 +1970,42 @@ function matchesDocumentUrl(eventUrl, documentPath) {
1760
1970
  }
1761
1971
  return pathname === documentPath || pathname === `${documentPath}/`;
1762
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
+ }
1763
2009
  function createContentPerformanceRouter(adapter) {
1764
2010
  const router = (0, import_express3.Router)();
1765
2011
  router.get("/content-performance", requireAdmin, async (req, res) => {
@@ -1778,16 +2024,15 @@ function createContentPerformanceRouter(adapter) {
1778
2024
  return;
1779
2025
  }
1780
2026
  const documentPath = `/${collection}/${documentId}`;
1781
- const pageViewResult = await adapter.query({
1782
- category: "page",
1783
- name: "page_view",
1784
- search: documentPath,
1785
- from,
1786
- to,
1787
- limit: 1e3
1788
- });
1789
- const pageViewEvents = pageViewResult.events.filter(
1790
- (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 }
1791
2036
  );
1792
2037
  const visitorSet = /* @__PURE__ */ new Set();
1793
2038
  const referrerMap = /* @__PURE__ */ new Map();
@@ -1802,38 +2047,26 @@ function createContentPerformanceRouter(adapter) {
1802
2047
  }
1803
2048
  let blockEngagement;
1804
2049
  try {
1805
- const [impressionResult, hoverResult] = await Promise.all([
1806
- adapter.query({
1807
- name: "block_impression",
1808
- search: documentPath,
1809
- from,
1810
- to,
1811
- limit: 1e3
1812
- }),
1813
- adapter.query({
1814
- name: "block_hover",
1815
- search: documentPath,
1816
- from,
1817
- to,
1818
- limit: 1e3
1819
- })
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
+ )
1820
2067
  ]);
1821
- const impressionEvents = impressionResult.events.filter(
1822
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1823
- );
1824
- const hoverEvents = hoverResult.events.filter(
1825
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1826
- );
1827
- const impressionMap = /* @__PURE__ */ new Map();
1828
- for (const event of impressionEvents) {
1829
- const bt = String(event.properties["blockType"] ?? "unknown");
1830
- impressionMap.set(bt, (impressionMap.get(bt) ?? 0) + 1);
1831
- }
1832
- const hoverMap = /* @__PURE__ */ new Map();
1833
- for (const event of hoverEvents) {
1834
- const bt = String(event.properties["blockType"] ?? "unknown");
1835
- hoverMap.set(bt, (hoverMap.get(bt) ?? 0) + 1);
1836
- }
2068
+ const impressionMap = countByBlockType(impressionEvents);
2069
+ const hoverMap = countByBlockType(hoverEvents);
1837
2070
  const allTypes = /* @__PURE__ */ new Set([...impressionMap.keys(), ...hoverMap.keys()]);
1838
2071
  if (allTypes.size > 0) {
1839
2072
  blockEngagement = [];
@@ -2086,11 +2319,11 @@ function analyticsPlugin(config) {
2086
2319
  // Browser-safe import paths for the admin config generator
2087
2320
  browserImports: {
2088
2321
  adminRoutes: {
2089
- path: "@momentumcms/plugins/analytics/admin-routes",
2322
+ path: "@momentumcms/plugins-analytics/admin-routes",
2090
2323
  exportName: "analyticsAdminRoutes"
2091
2324
  },
2092
2325
  modifyCollections: {
2093
- path: "@momentumcms/plugins/analytics/block-fields",
2326
+ path: "@momentumcms/plugins-analytics/block-fields",
2094
2327
  exportName: "injectBlockAnalyticsFields"
2095
2328
  }
2096
2329
  },
@@ -2141,6 +2374,19 @@ function analyticsPlugin(config) {
2141
2374
  position: "before-api"
2142
2375
  });
2143
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
+ }
2144
2390
  if (config.contentPerformance !== false) {
2145
2391
  const contentPerfRouter = createContentPerformanceRouter(config.adapter);
2146
2392
  registerMiddleware({
@@ -2226,16 +2472,20 @@ init_rule_engine();
2226
2472
  TrackingRules,
2227
2473
  analyticsPlugin,
2228
2474
  attachBlockTracking,
2475
+ compileContentRoutes,
2229
2476
  createAnalyticsMiddleware,
2230
2477
  createAnalyticsQueryRouter,
2231
2478
  createApiCollectorMiddleware,
2232
2479
  createContentPerformanceRouter,
2233
2480
  createIngestRouter,
2481
+ createPageViewCollectorMiddleware,
2234
2482
  createRuleEngine,
2235
2483
  createTracker,
2236
2484
  createTrackingRulesRouter,
2237
2485
  injectBlockAnalyticsFields,
2238
2486
  injectCollectionCollector,
2487
+ isBot,
2488
+ matchContentRoute,
2239
2489
  parseUserAgent,
2240
2490
  postgresAnalyticsAdapter
2241
2491
  });