@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.js CHANGED
@@ -14,6 +14,10 @@ __export(block_tracker_exports, {
14
14
  attachBlockTracking: () => attachBlockTracking
15
15
  });
16
16
  function attachBlockTracking(tracker, container) {
17
+ if (typeof document === "undefined" || !document.body) {
18
+ return () => {
19
+ };
20
+ }
17
21
  const root = container ?? document.body;
18
22
  const impressionsSeen = /* @__PURE__ */ new Set();
19
23
  const hoverCooldowns = /* @__PURE__ */ new Map();
@@ -827,8 +831,210 @@ function createApiCollectorMiddleware(emitter) {
827
831
  };
828
832
  }
829
833
 
830
- // libs/plugins/analytics/src/lib/ingest-handler.ts
834
+ // libs/plugins/analytics/src/lib/collectors/page-view-collector.ts
831
835
  import { randomUUID as randomUUID3 } from "node:crypto";
836
+
837
+ // libs/plugins/analytics/src/lib/utils/content-route-matcher.ts
838
+ function escapeRegex(str) {
839
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
840
+ }
841
+ function compileContentRoute(collection, pattern) {
842
+ const segments = pattern.split("/").filter(Boolean);
843
+ const paramNames = [];
844
+ let staticCount = 0;
845
+ const regexParts = segments.map((seg) => {
846
+ if (seg.startsWith(":")) {
847
+ paramNames.push(seg.slice(1));
848
+ return "([^/]+)";
849
+ }
850
+ staticCount++;
851
+ return escapeRegex(seg);
852
+ });
853
+ const regexStr = "^/" + regexParts.join("/") + "/?$";
854
+ return {
855
+ collection,
856
+ pattern,
857
+ regex: new RegExp(regexStr),
858
+ paramNames,
859
+ staticSegments: staticCount
860
+ };
861
+ }
862
+ function compileContentRoutes(routes) {
863
+ const compiled = Object.entries(routes).map(
864
+ ([collection, pattern]) => compileContentRoute(collection, pattern)
865
+ );
866
+ compiled.sort((a, b) => b.staticSegments - a.staticSegments);
867
+ return compiled;
868
+ }
869
+ function matchContentRoute(path, routes) {
870
+ for (const route of routes) {
871
+ const match = route.regex.exec(path);
872
+ if (match) {
873
+ const params = {};
874
+ for (let i = 0; i < route.paramNames.length; i++) {
875
+ params[route.paramNames[i]] = match[i + 1];
876
+ }
877
+ return { collection: route.collection, params };
878
+ }
879
+ }
880
+ return void 0;
881
+ }
882
+
883
+ // libs/plugins/analytics/src/lib/collectors/page-view-collector.ts
884
+ var DEFAULT_EXCLUDE_EXTENSIONS = /* @__PURE__ */ new Set([
885
+ ".js",
886
+ ".css",
887
+ ".ico",
888
+ ".png",
889
+ ".jpg",
890
+ ".jpeg",
891
+ ".gif",
892
+ ".svg",
893
+ ".webp",
894
+ ".avif",
895
+ ".woff",
896
+ ".woff2",
897
+ ".ttf",
898
+ ".eot",
899
+ ".map",
900
+ ".json",
901
+ ".xml",
902
+ ".txt",
903
+ ".mp4",
904
+ ".webm",
905
+ ".ogg",
906
+ ".mp3"
907
+ ]);
908
+ var ALWAYS_EXCLUDED_PREFIXES = [
909
+ "/api/",
910
+ "/admin",
911
+ "/__vite",
912
+ "/@fs/",
913
+ "/@id/",
914
+ "/.analog/",
915
+ "/node_modules/"
916
+ ];
917
+ var ALWAYS_EXCLUDED_EXACT = /* @__PURE__ */ new Set([
918
+ "/api",
919
+ "/favicon.ico",
920
+ "/robots.txt",
921
+ "/sitemap.xml",
922
+ "/sitemap-index.xml",
923
+ "/health",
924
+ "/healthz",
925
+ "/ready",
926
+ "/.well-known/security.txt"
927
+ ]);
928
+ 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;
929
+ function isBot(ua) {
930
+ if (!ua)
931
+ return false;
932
+ return BOT_PATTERN.test(ua);
933
+ }
934
+ function shouldExcludePath(path, excludeExtensions, excludePaths) {
935
+ if (ALWAYS_EXCLUDED_EXACT.has(path))
936
+ return true;
937
+ for (const prefix of ALWAYS_EXCLUDED_PREFIXES) {
938
+ if (path.startsWith(prefix))
939
+ return true;
940
+ }
941
+ for (const pattern of excludePaths) {
942
+ if (path.startsWith(pattern))
943
+ return true;
944
+ }
945
+ const dotIndex = path.lastIndexOf(".");
946
+ if (dotIndex !== -1) {
947
+ const ext = path.slice(dotIndex).toLowerCase();
948
+ if (excludeExtensions.has(ext))
949
+ return true;
950
+ }
951
+ return false;
952
+ }
953
+ function extractUserId(req) {
954
+ if (!("user" in req))
955
+ return void 0;
956
+ const user = req["user"];
957
+ if (user == null || typeof user !== "object")
958
+ return void 0;
959
+ if (!("id" in user))
960
+ return void 0;
961
+ const id = user.id;
962
+ return typeof id === "string" ? id : void 0;
963
+ }
964
+ function createPageViewCollectorMiddleware(emitter, options = {}) {
965
+ const excludeExtensions = options.excludeExtensions ? new Set(options.excludeExtensions) : DEFAULT_EXCLUDE_EXTENSIONS;
966
+ const excludePaths = options.excludePaths ?? [];
967
+ const onlySuccessful = options.onlySuccessful !== false;
968
+ const trackBots = options.trackBots === true;
969
+ const compiledRoutes = options.contentRoutes ? compileContentRoutes(options.contentRoutes) : void 0;
970
+ return (req, res, next) => {
971
+ if (req.method !== "GET") {
972
+ next();
973
+ return;
974
+ }
975
+ const path = req.path;
976
+ if (shouldExcludePath(path, excludeExtensions, excludePaths)) {
977
+ next();
978
+ return;
979
+ }
980
+ const ua = req.headers["user-agent"];
981
+ if (!trackBots && isBot(ua)) {
982
+ next();
983
+ return;
984
+ }
985
+ const start = Date.now();
986
+ res.once("finish", () => {
987
+ if (onlySuccessful && (res.statusCode < 200 || res.statusCode >= 300)) {
988
+ return;
989
+ }
990
+ const duration = Date.now() - start;
991
+ const parsed = parseUserAgent(ua);
992
+ const refHeader = req.headers["referer"] ?? req.headers["referrer"];
993
+ const referrer = Array.isArray(refHeader) ? refHeader[0] : refHeader;
994
+ let contentCollection;
995
+ let contentSlug;
996
+ if (compiledRoutes) {
997
+ const routeMatch = matchContentRoute(path, compiledRoutes);
998
+ if (routeMatch) {
999
+ contentCollection = routeMatch.collection;
1000
+ contentSlug = routeMatch.params["slug"];
1001
+ }
1002
+ }
1003
+ const event = {
1004
+ id: randomUUID3(),
1005
+ category: "page",
1006
+ name: "page_view",
1007
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1008
+ userId: extractUserId(req),
1009
+ properties: {
1010
+ method: req.method,
1011
+ path,
1012
+ statusCode: res.statusCode,
1013
+ ...contentCollection != null ? { collection: contentCollection } : {},
1014
+ ...contentSlug != null ? { slug: contentSlug } : {}
1015
+ },
1016
+ context: {
1017
+ source: "server",
1018
+ url: req.originalUrl,
1019
+ referrer,
1020
+ userAgent: ua,
1021
+ ip: req.ip ?? req.socket.remoteAddress,
1022
+ device: parsed.device,
1023
+ browser: parsed.browser,
1024
+ os: parsed.os,
1025
+ duration,
1026
+ statusCode: res.statusCode,
1027
+ ...contentCollection != null ? { collection: contentCollection } : {}
1028
+ }
1029
+ };
1030
+ emitter(event);
1031
+ });
1032
+ next();
1033
+ };
1034
+ }
1035
+
1036
+ // libs/plugins/analytics/src/lib/ingest-handler.ts
1037
+ import { randomUUID as randomUUID4 } from "node:crypto";
832
1038
  import { Router as createRouter } from "express";
833
1039
 
834
1040
  // libs/core/src/lib/collections/define-collection.ts
@@ -1122,7 +1328,7 @@ function createIngestRouter(options) {
1122
1328
  }
1123
1329
  const partial = raw;
1124
1330
  const event = {
1125
- id: randomUUID3(),
1331
+ id: randomUUID4(),
1126
1332
  category: partial.category ?? "custom",
1127
1333
  name: partial.name ?? "unknown",
1128
1334
  // Server-side timestamp (prevents client clock skew)
@@ -1728,6 +1934,42 @@ function matchesDocumentUrl(eventUrl, documentPath) {
1728
1934
  }
1729
1935
  return pathname === documentPath || pathname === `${documentPath}/`;
1730
1936
  }
1937
+ async function queryEventsByDocument(queryFn, eventName, collection, documentId, documentPath, options) {
1938
+ const queryBase = {
1939
+ ...options.category ? { category: options.category } : {},
1940
+ name: eventName,
1941
+ from: options.from,
1942
+ to: options.to,
1943
+ limit: 1e3
1944
+ };
1945
+ const [serverResult, allResult] = await Promise.all([
1946
+ queryFn({ ...queryBase, collection }),
1947
+ queryFn(queryBase)
1948
+ ]);
1949
+ const seen = /* @__PURE__ */ new Set();
1950
+ const results = [];
1951
+ for (const e of [...serverResult.events, ...allResult.events]) {
1952
+ if (seen.has(e.id))
1953
+ continue;
1954
+ seen.add(e.id);
1955
+ const matchesCollection = e.context.collection === collection || e.properties["collection"] === collection;
1956
+ const matchesSlug = typeof e.properties["slug"] === "string" && e.properties["slug"] === documentId;
1957
+ if (matchesCollection && matchesSlug)
1958
+ results.push(e);
1959
+ }
1960
+ if (results.length > 0)
1961
+ return results;
1962
+ const urlResult = await queryFn({ ...queryBase, search: documentPath });
1963
+ return urlResult.events.filter((e) => matchesDocumentUrl(e.context.url, documentPath));
1964
+ }
1965
+ function countByBlockType(events) {
1966
+ const map = /* @__PURE__ */ new Map();
1967
+ for (const event of events) {
1968
+ const bt = String(event.properties["blockType"] ?? "unknown");
1969
+ map.set(bt, (map.get(bt) ?? 0) + 1);
1970
+ }
1971
+ return map;
1972
+ }
1731
1973
  function createContentPerformanceRouter(adapter) {
1732
1974
  const router = Router2();
1733
1975
  router.get("/content-performance", requireAdmin, async (req, res) => {
@@ -1746,16 +1988,15 @@ function createContentPerformanceRouter(adapter) {
1746
1988
  return;
1747
1989
  }
1748
1990
  const documentPath = `/${collection}/${documentId}`;
1749
- const pageViewResult = await adapter.query({
1750
- category: "page",
1751
- name: "page_view",
1752
- search: documentPath,
1753
- from,
1754
- to,
1755
- limit: 1e3
1756
- });
1757
- const pageViewEvents = pageViewResult.events.filter(
1758
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1991
+ const queryFn = adapter.query.bind(adapter);
1992
+ const dateRange = { from, to };
1993
+ const pageViewEvents = await queryEventsByDocument(
1994
+ queryFn,
1995
+ "page_view",
1996
+ collection,
1997
+ documentId,
1998
+ documentPath,
1999
+ { category: "page", ...dateRange }
1759
2000
  );
1760
2001
  const visitorSet = /* @__PURE__ */ new Set();
1761
2002
  const referrerMap = /* @__PURE__ */ new Map();
@@ -1770,38 +2011,26 @@ function createContentPerformanceRouter(adapter) {
1770
2011
  }
1771
2012
  let blockEngagement;
1772
2013
  try {
1773
- const [impressionResult, hoverResult] = await Promise.all([
1774
- adapter.query({
1775
- name: "block_impression",
1776
- search: documentPath,
1777
- from,
1778
- to,
1779
- limit: 1e3
1780
- }),
1781
- adapter.query({
1782
- name: "block_hover",
1783
- search: documentPath,
1784
- from,
1785
- to,
1786
- limit: 1e3
1787
- })
2014
+ const [impressionEvents, hoverEvents] = await Promise.all([
2015
+ queryEventsByDocument(
2016
+ queryFn,
2017
+ "block_impression",
2018
+ collection,
2019
+ documentId,
2020
+ documentPath,
2021
+ dateRange
2022
+ ),
2023
+ queryEventsByDocument(
2024
+ queryFn,
2025
+ "block_hover",
2026
+ collection,
2027
+ documentId,
2028
+ documentPath,
2029
+ dateRange
2030
+ )
1788
2031
  ]);
1789
- const impressionEvents = impressionResult.events.filter(
1790
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1791
- );
1792
- const hoverEvents = hoverResult.events.filter(
1793
- (e) => matchesDocumentUrl(e.context.url, documentPath)
1794
- );
1795
- const impressionMap = /* @__PURE__ */ new Map();
1796
- for (const event of impressionEvents) {
1797
- const bt = String(event.properties["blockType"] ?? "unknown");
1798
- impressionMap.set(bt, (impressionMap.get(bt) ?? 0) + 1);
1799
- }
1800
- const hoverMap = /* @__PURE__ */ new Map();
1801
- for (const event of hoverEvents) {
1802
- const bt = String(event.properties["blockType"] ?? "unknown");
1803
- hoverMap.set(bt, (hoverMap.get(bt) ?? 0) + 1);
1804
- }
2032
+ const impressionMap = countByBlockType(impressionEvents);
2033
+ const hoverMap = countByBlockType(hoverEvents);
1805
2034
  const allTypes = /* @__PURE__ */ new Set([...impressionMap.keys(), ...hoverMap.keys()]);
1806
2035
  if (allTypes.size > 0) {
1807
2036
  blockEngagement = [];
@@ -2054,11 +2283,11 @@ function analyticsPlugin(config) {
2054
2283
  // Browser-safe import paths for the admin config generator
2055
2284
  browserImports: {
2056
2285
  adminRoutes: {
2057
- path: "@momentumcms/plugins/analytics/admin-routes",
2286
+ path: "@momentumcms/plugins-analytics/admin-routes",
2058
2287
  exportName: "analyticsAdminRoutes"
2059
2288
  },
2060
2289
  modifyCollections: {
2061
- path: "@momentumcms/plugins/analytics/block-fields",
2290
+ path: "@momentumcms/plugins-analytics/block-fields",
2062
2291
  exportName: "injectBlockAnalyticsFields"
2063
2292
  }
2064
2293
  },
@@ -2109,6 +2338,19 @@ function analyticsPlugin(config) {
2109
2338
  position: "before-api"
2110
2339
  });
2111
2340
  }
2341
+ if (config.trackPageViews !== false) {
2342
+ const pageViewOptions = typeof config.trackPageViews === "object" ? config.trackPageViews : {};
2343
+ const pageViewCollector = createPageViewCollectorMiddleware(
2344
+ (event) => eventStore.add(event),
2345
+ pageViewOptions
2346
+ );
2347
+ registerMiddleware({
2348
+ path: "/",
2349
+ handler: pageViewCollector,
2350
+ position: "root"
2351
+ });
2352
+ logger.info("Page view tracking enabled");
2353
+ }
2112
2354
  if (config.contentPerformance !== false) {
2113
2355
  const contentPerfRouter = createContentPerformanceRouter(config.adapter);
2114
2356
  registerMiddleware({
@@ -2193,16 +2435,20 @@ export {
2193
2435
  TrackingRules,
2194
2436
  analyticsPlugin,
2195
2437
  attachBlockTracking,
2438
+ compileContentRoutes,
2196
2439
  createAnalyticsMiddleware,
2197
2440
  createAnalyticsQueryRouter,
2198
2441
  createApiCollectorMiddleware,
2199
2442
  createContentPerformanceRouter,
2200
2443
  createIngestRouter,
2444
+ createPageViewCollectorMiddleware,
2201
2445
  createRuleEngine,
2202
2446
  createTracker,
2203
2447
  createTrackingRulesRouter,
2204
2448
  injectBlockAnalyticsFields,
2205
2449
  injectCollectionCollector,
2450
+ isBot,
2451
+ matchContentRoute,
2206
2452
  parseUserAgent,
2207
2453
  postgresAnalyticsAdapter
2208
2454
  };