From b17b1cb336e7233396e63783d77fef74b70b0249 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Tue, 29 Sep 2026 13:48:48 -0400 Subject: [PATCH 01/14] feat(sdk-events): add @dotcms/events for pageviews, content events and experiments @dotcms/events replaces @dotcms/analytics and @dotcms/experiments with one events object that runs a single Analytics.js instance. It sends only what dotCMS accepts today: pageviews, conversions, content impressions and content clicks. Entries: - @dotcms/events: `events` (init, conversion, pageView) and the types its API uses. - @dotcms/events/markup: `experimentMarkup`, what a server-rendered page prints so its experiment runs. - @dotcms/events/react: `DotCMSExperiment`, which prints that markup. - ca.min.js (build:standalone): the IIFE dotCMS injects into traditional pages. It reads the attributes of ca/html/analytics_head.html, and decides experiments from the contentlet wrappers dotCMS prints. Experiments: a boot script decides returning visitors while the HTML is parsed, and the engine decides new visitors; both run decideVariant. The engine asks isUserIncluded, redirects to the assigned variant, and adds context.experiments to every event. Layout: one folder per capability under src/lib (pipeline, contentlets, impressions, clicks, experiments, react, standalone), files named by their role, and the boundaries between them enforced with no-restricted-imports. State lives in localStorage and sessionStorage under dot_events_*, with no cookies. onError reports the requests dotCMS rejects or never answers. The nextjs-experiments example runs on the package. Refs #37683, #37798 --- core-web/libs/sdk/bundle-budgets/budgets.json | 2 + core-web/libs/sdk/bundle-budgets/project.json | 3 +- .../bundle-budgets/src/declared-deps.spec.ts | 12 +- .../libs/sdk/bundle-budgets/src/probes.ts | 13 + .../sdk/bundle-budgets/src/publint.spec.ts | 2 +- core-web/libs/sdk/events/CLAUDE.md | 406 +++++++ core-web/libs/sdk/events/README.md | 149 +++ core-web/libs/sdk/events/eslint.config.mjs | 273 +++++ core-web/libs/sdk/events/package.json | 63 + core-web/libs/sdk/events/project.json | 51 + core-web/libs/sdk/events/rollup.config.cjs | 40 + core-web/libs/sdk/events/src/index.ts | 20 + .../sdk/events/src/lib/clicks/constants.ts | 20 + .../libs/sdk/events/src/lib/clicks/plugin.ts | 85 ++ .../sdk/events/src/lib/clicks/tracker.spec.ts | 597 ++++++++++ .../libs/sdk/events/src/lib/clicks/tracker.ts | 261 +++++ .../sdk/events/src/lib/clicks/utils.spec.ts | 489 ++++++++ .../libs/sdk/events/src/lib/clicks/utils.ts | 100 ++ .../events/src/lib/contentlets/constants.ts | 43 + .../events/src/lib/contentlets/utils.spec.ts | 133 +++ .../sdk/events/src/lib/contentlets/utils.ts | 213 ++++ .../src/lib/contentlets/viewport.spec.ts | 340 ++++++ .../events/src/lib/contentlets/viewport.ts | 57 + .../libs/sdk/events/src/lib/events.spec.ts | 69 ++ core-web/libs/sdk/events/src/lib/events.ts | 338 ++++++ .../sdk/events/src/lib/experiments/api.ts | 59 + .../events/src/lib/experiments/boot.spec.ts | 230 ++++ .../sdk/events/src/lib/experiments/boot.ts | 164 +++ .../events/src/lib/experiments/constants.ts | 61 + .../src/lib/experiments/decision.spec.ts | 122 ++ .../events/src/lib/experiments/decision.ts | 88 ++ .../events/src/lib/experiments/dom.spec.ts | 75 ++ .../sdk/events/src/lib/experiments/dom.ts | 154 +++ .../events/src/lib/experiments/engine.spec.ts | 449 +++++++ .../sdk/events/src/lib/experiments/engine.ts | 407 +++++++ .../events/src/lib/experiments/markup.spec.ts | 31 + .../sdk/events/src/lib/experiments/markup.ts | 37 + .../sdk/events/src/lib/experiments/models.ts | 56 + .../sdk/events/src/lib/experiments/plugin.ts | 43 + .../sdk/events/src/lib/experiments/store.ts | 187 +++ .../events/src/lib/impressions/constants.ts | 43 + .../events/src/lib/impressions/plugin.spec.ts | 421 +++++++ .../sdk/events/src/lib/impressions/plugin.ts | 95 ++ .../src/lib/impressions/tracker.spec.ts | 1027 +++++++++++++++++ .../sdk/events/src/lib/impressions/tracker.ts | 529 +++++++++ .../events/src/lib/impressions/utils.spec.ts | 127 ++ .../sdk/events/src/lib/impressions/utils.ts | 19 + core-web/libs/sdk/events/src/lib/models.ts | 147 +++ .../sdk/events/src/lib/pipeline/constants.ts | 132 +++ .../src/lib/pipeline/enricher/plugin.spec.ts | 257 +++++ .../src/lib/pipeline/enricher/plugin.ts | 82 ++ .../lib/pipeline/identity/activity.spec.ts | 225 ++++ .../src/lib/pipeline/identity/activity.ts | 156 +++ .../src/lib/pipeline/identity/plugin.ts | 84 ++ .../sdk/events/src/lib/pipeline/logger.ts | 146 +++ .../sdk/events/src/lib/pipeline/models.ts | 656 +++++++++++ .../src/lib/pipeline/sender/http.spec.ts | 357 ++++++ .../events/src/lib/pipeline/sender/http.ts | 167 +++ .../events/src/lib/pipeline/sender/plugin.ts | 216 ++++ .../src/lib/pipeline/sender/queue-utils.d.ts | 23 + .../src/lib/pipeline/sender/queue.spec.ts | 974 ++++++++++++++++ .../events/src/lib/pipeline/sender/queue.ts | 446 +++++++ .../src/lib/pipeline/sender/router-utils.d.ts | 17 + .../sdk/events/src/lib/pipeline/utils.spec.ts | 613 ++++++++++ .../libs/sdk/events/src/lib/pipeline/utils.ts | 511 ++++++++ .../src/lib/react/DotCMSExperiment.spec.tsx | 56 + .../events/src/lib/react/DotCMSExperiment.tsx | 64 + .../events/src/lib/standalone/config.spec.ts | 128 ++ .../sdk/events/src/lib/standalone/config.ts | 174 +++ core-web/libs/sdk/events/src/markup.ts | 8 + core-web/libs/sdk/events/src/react.ts | 9 + .../libs/sdk/events/src/standalone.spec.ts | 39 + core-web/libs/sdk/events/src/standalone.ts | 26 + core-web/libs/sdk/events/tsconfig.json | 43 + core-web/libs/sdk/events/tsconfig.lib.json | 20 + core-web/libs/sdk/events/tsconfig.spec.json | 22 + core-web/libs/sdk/events/vite.config.mts | 57 + .../sdk/events/vite.standalone.config.mts | 59 + core-web/nx.json | 1 + core-web/tsconfig.base.json | 1 + .../nextjs-experiments/.env.local.example | 4 +- examples/nextjs-experiments/.npmrc | 5 + examples/nextjs-experiments/package-lock.json | 303 +++-- examples/nextjs-experiments/package.json | 10 +- .../src/app/[[...slug]]/page.tsx | 9 +- .../nextjs-experiments/src/app/blog/page.tsx | 23 +- .../src/components/content-types/index.ts | 1 + .../src/components/forms/ContactUs.tsx | 4 + .../src/config/dotcms.config.ts | 27 +- .../src/instrumentation-client.ts | 7 + .../src/views/BlogListingPage.tsx | 14 +- .../nextjs-experiments/src/views/Page.tsx | 14 +- 92 files changed, 14336 insertions(+), 204 deletions(-) create mode 100644 core-web/libs/sdk/events/CLAUDE.md create mode 100644 core-web/libs/sdk/events/README.md create mode 100644 core-web/libs/sdk/events/eslint.config.mjs create mode 100644 core-web/libs/sdk/events/package.json create mode 100644 core-web/libs/sdk/events/project.json create mode 100644 core-web/libs/sdk/events/rollup.config.cjs create mode 100644 core-web/libs/sdk/events/src/index.ts create mode 100644 core-web/libs/sdk/events/src/lib/clicks/constants.ts create mode 100644 core-web/libs/sdk/events/src/lib/clicks/plugin.ts create mode 100644 core-web/libs/sdk/events/src/lib/clicks/tracker.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/clicks/tracker.ts create mode 100644 core-web/libs/sdk/events/src/lib/clicks/utils.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/clicks/utils.ts create mode 100644 core-web/libs/sdk/events/src/lib/contentlets/constants.ts create mode 100644 core-web/libs/sdk/events/src/lib/contentlets/utils.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/contentlets/utils.ts create mode 100644 core-web/libs/sdk/events/src/lib/contentlets/viewport.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/contentlets/viewport.ts create mode 100644 core-web/libs/sdk/events/src/lib/events.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/events.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/api.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/boot.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/boot.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/constants.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/decision.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/decision.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/dom.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/dom.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/engine.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/engine.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/markup.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/markup.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/models.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/plugin.ts create mode 100644 core-web/libs/sdk/events/src/lib/experiments/store.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/constants.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/plugin.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/plugin.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/tracker.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/tracker.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/utils.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/impressions/utils.ts create mode 100644 core-web/libs/sdk/events/src/lib/models.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/constants.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/enricher/plugin.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/enricher/plugin.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/identity/activity.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/identity/activity.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/identity/plugin.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/logger.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/models.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/http.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/http.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/plugin.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/queue-utils.d.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/queue.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/queue.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/sender/router-utils.d.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/utils.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/pipeline/utils.ts create mode 100644 core-web/libs/sdk/events/src/lib/react/DotCMSExperiment.spec.tsx create mode 100644 core-web/libs/sdk/events/src/lib/react/DotCMSExperiment.tsx create mode 100644 core-web/libs/sdk/events/src/lib/standalone/config.spec.ts create mode 100644 core-web/libs/sdk/events/src/lib/standalone/config.ts create mode 100644 core-web/libs/sdk/events/src/markup.ts create mode 100644 core-web/libs/sdk/events/src/react.ts create mode 100644 core-web/libs/sdk/events/src/standalone.spec.ts create mode 100644 core-web/libs/sdk/events/src/standalone.ts create mode 100644 core-web/libs/sdk/events/tsconfig.json create mode 100644 core-web/libs/sdk/events/tsconfig.lib.json create mode 100644 core-web/libs/sdk/events/tsconfig.spec.json create mode 100644 core-web/libs/sdk/events/vite.config.mts create mode 100644 core-web/libs/sdk/events/vite.standalone.config.mts create mode 100644 examples/nextjs-experiments/.npmrc create mode 100644 examples/nextjs-experiments/src/instrumentation-client.ts diff --git a/core-web/libs/sdk/bundle-budgets/budgets.json b/core-web/libs/sdk/bundle-budgets/budgets.json index 5bafff3d333c..b6a9b752b5e3 100644 --- a/core-web/libs/sdk/bundle-budgets/budgets.json +++ b/core-web/libs/sdk/bundle-budgets/budgets.json @@ -3,5 +3,7 @@ "react-layout-only": 7500, "react-hook-only": 5200, "analytics-neutral": 25000, + "events-init": 30000, + "events-react": 2200, "uve-only": 3700 } diff --git a/core-web/libs/sdk/bundle-budgets/project.json b/core-web/libs/sdk/bundle-budgets/project.json index f56014c34652..ca552fdbfc5f 100644 --- a/core-web/libs/sdk/bundle-budgets/project.json +++ b/core-web/libs/sdk/bundle-budgets/project.json @@ -12,7 +12,8 @@ "sdk-analytics", "sdk-angular", "sdk-vue", - "sdk-experiments" + "sdk-experiments", + "sdk-events" ], "targets": { "test": { diff --git a/core-web/libs/sdk/bundle-budgets/src/declared-deps.spec.ts b/core-web/libs/sdk/bundle-budgets/src/declared-deps.spec.ts index af4194ad39a5..e003e4e048ed 100644 --- a/core-web/libs/sdk/bundle-budgets/src/declared-deps.spec.ts +++ b/core-web/libs/sdk/bundle-budgets/src/declared-deps.spec.ts @@ -34,7 +34,17 @@ import { SDK_DIST } from './bundle-probe.ts'; */ /** Published libraries. Each must be built before this spec runs — see `implicitDependencies`. */ -const PACKAGES = ['react', 'client', 'uve', 'types', 'analytics', 'angular', 'vue', 'experiments']; +const PACKAGES = [ + 'react', + 'client', + 'uve', + 'types', + 'analytics', + 'angular', + 'vue', + 'experiments', + 'events' +]; /** `from 'x'`, `import 'x'`, `import('x')` and `require('x')` — not bare strings that merely look like specifiers. */ const SPECIFIER = /(?:from|import|require)\s*\(?\s*['"](@dotcms\/[^'"]+)['"]/g; diff --git a/core-web/libs/sdk/bundle-budgets/src/probes.ts b/core-web/libs/sdk/bundle-budgets/src/probes.ts index 4f1083a038ba..f32ad45798ce 100644 --- a/core-web/libs/sdk/bundle-budgets/src/probes.ts +++ b/core-web/libs/sdk/bundle-budgets/src/probes.ts @@ -42,6 +42,19 @@ export const PROBES: Probe[] = [ packages: ['analytics', 'uve', 'types'], forbidden: ['/react/', 'next/navigation', 'useContentAnalytics', 'DotContentAnalytics'] }, + { + name: 'events-init', + source: `import { events } from '@dotcms/events';\nexport { events };\n`, + packages: ['events', 'uve', 'types'], + forbidden: ['/react/', 'next/navigation', 'tinymce'] + }, + { + // The React adapter brings the markup and the boot script builder, never the engine + name: 'events-react', + source: `import { DotCMSExperiment } from '@dotcms/events/react';\nexport { DotCMSExperiment };\n`, + packages: ['events'], + forbidden: ['@dotcms/events/index', '/analytics/', '@analytics/'] + }, { name: 'uve-only', source: `import { getUVEState } from '@dotcms/uve';\nexport { getUVEState };\n`, diff --git a/core-web/libs/sdk/bundle-budgets/src/publint.spec.ts b/core-web/libs/sdk/bundle-budgets/src/publint.spec.ts index 2564140bed05..a76caabc10ed 100644 --- a/core-web/libs/sdk/bundle-budgets/src/publint.spec.ts +++ b/core-web/libs/sdk/bundle-budgets/src/publint.spec.ts @@ -33,7 +33,7 @@ const ACCEPTED: Record = { EXPORTS_TYPES_INVALID_FORMAT: 'needs .d.mts output; types resolve correctly today' }; describe('publint', () => { - const PACKAGES = ['react', 'client', 'uve', 'types', 'analytics']; + const PACKAGES = ['react', 'client', 'uve', 'types', 'analytics', 'events']; describe.each(PACKAGES)('@dotcms/%s', (name) => { test('should publish a valid package', async () => { diff --git a/core-web/libs/sdk/events/CLAUDE.md b/core-web/libs/sdk/events/CLAUDE.md new file mode 100644 index 000000000000..1381939df42c --- /dev/null +++ b/core-web/libs/sdk/events/CLAUDE.md @@ -0,0 +1,406 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`@dotcms/events` is the dotCMS SDK for everything a page reports back to dotCMS: automatic pageviews, conversions, content clicks, content impressions, and experiments (A/B tests). It is greenfield: it sends only what dotCMS accepts today. It replaces `@dotcms/analytics` and `@dotcms/experiments`, which are deprecated. + +One object, `events`, configured by one `init` call, runs a single Analytics.js instance. Experiments run inside that instance: an engine asks dotCMS which variant the visitor gets and redirects to it, and a plugin adds `context.experiments` to every event. The event payload is the one `@dotcms/analytics` sends. + +The core is framework-free: the same object serves Next.js and plain React, and traditional (VTL) pages get it as `ca.min.js`, an IIFE dotCMS injects (see Traditional Pages). What a page prints so an experiment runs comes from this package too: `experimentMarkup` (`@dotcms/events/markup`) for any framework, and `DotCMSExperiment` (`@dotcms/events/react`) for React. The other SDKs are not involved: `@dotcms/uve` and `@dotcms/react` have no experiment code. + +Status: prototype, not released yet. + +## Essential Commands + +```bash +# From core-web. Nx is not installed globally: always go through pnpm. +pnpm nx build sdk-events # ESM + CJS package in dist/libs/sdk/events +pnpm nx test sdk-events # Vitest +pnpm nx lint sdk-events # ESLint, including the layer boundaries (below) + +# One spec file +pnpm nx test sdk-events -- src/lib/impressions/tracker.spec.ts + +# Size, publint and declared-dependency checks on the built package (builds every SDK first) +pnpm nx test sdk-bundle-budgets + +# ca.min.js, the IIFE for traditional pages, in dist/libs/sdk/events-standalone +pnpm nx run sdk-events:build:standalone + +# Current gzip size of every bundle probe, to review libs/sdk/bundle-budgets/budgets.json +./node_modules/.bin/tsx libs/sdk/bundle-budgets/src/measure.ts +``` + +`npx` fails in this repo with `EBADDEVENGINES` (the root `devEngines` requires pnpm), so run binaries from `node_modules/.bin` or through `pnpm exec`. + +## How It Is Used + +```ts +// src/instrumentation-client.ts (Next.js 15.3+): runs after the HTML loads, before hydration +import { events } from '@dotcms/events'; + +events.init({ + dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_HOST!, + siteAuth: process.env.NEXT_PUBLIC_DOTCMS_SITE_AUTH!, + impressions: true, + clicks: true +}); +``` + +```ts +// Any other module gets the same object, already configured +import { events } from '@dotcms/events'; + +events.conversion('signup'); +events.pageView({ campaign: 'spring' }); // only needed with autoPageView: false +``` + +```tsx +// Wrap what the experiment varies. In a server component it costs the client no JavaScript. +import { DotCMSExperiment } from '@dotcms/events/react'; + + + + +``` + +- `dotcmsUrl` and `siteAuth` (the Site Auth from the Content Analytics app) are required. +- Experiments and automatic pageviews are on by default. Impressions and clicks are opt-in, as in `@dotcms/analytics`. +- After `init`, the object is also `window.dotEvents`, for traditional pages and plain scripts. +- Calls made before Analytics.js loads, `init` included, are buffered (up to 50) and replayed once it does. +- `init` runs once. A second call with the same config does nothing; one with another config is ignored with a warning. +- On the server and inside the UVE editor every call is a no-op. Inside UVE the experiment's content is revealed, so nothing stays hidden while editing. +- `conversion` takes a name only, and there is no `track`: the SDK sends only the event types dotCMS accepts (see Events It Sends). +- `DotCMSExperiment` takes only the page: its script never calls dotCMS, so it needs no URL. + +The full option table is in README.md. + +## Architecture Overview + +### Project Structure + +``` +libs/sdk/events/ +├── src/ +│ ├── index.ts # Public entry: `events` and the types its methods, options and errors use +│ ├── markup.ts # Public entry ./markup: `experimentMarkup` and its types +│ ├── react.ts # Public entry ./react: `DotCMSExperiment` and its props +│ ├── standalone.ts # The IIFE entry, not an export: ca.min.js for traditional pages +│ └── lib/ +│ ├── events.ts # The `events` object: init, conversion, pageView, automatic pageviews +│ ├── models.ts # Public types (DotCMSEvents*) +│ ├── pipeline/ # What every event goes through, in Analytics.js plugins +│ │ ├── identity/ # plugin.ts: context (site, session, user, device); activity.ts: session activity +│ │ ├── enricher/ # plugin.ts: page, UTM and custom data +│ │ ├── sender/ # plugin.ts: builds the dotCMS event; queue.ts; http.ts, and onError +│ │ ├── constants.ts # Event types, endpoint, storage keys, SENDER_PLUGIN_NAME +│ │ ├── models.ts # The pipeline's config and payloads, and the shapes dotCMS receives +│ │ ├── logger.ts +│ │ └── utils.ts # Context, session, user id, page data +│ ├── contentlets/ # What the content trackers share +│ │ ├── constants.ts # Contentlet class and attribute, observer debounce, the rescan event +│ │ ├── utils.ts # Finding and reading contentlets, their DOM observer, the tracker plugins' helpers +│ │ └── viewport.ts # Where a contentlet sits in the viewport +│ ├── impressions/ # plugin.ts, tracker.ts (IntersectionObserver + dwell), utils.ts, constants.ts +│ ├── clicks/ # plugin.ts, tracker.ts, utils.ts, constants.ts +│ ├── experiments/ # The experiments engine +│ │ ├── api.ts # POST /api/v1/experiments/isUserIncluded +│ │ ├── boot.ts # The boot script: applies the decision while the HTML is parsed +│ │ ├── constants.ts # The experiment contract: attributes, hiding rule, timeouts, storage keys +│ │ ├── decision.ts # decideVariant: the decision the boot script and the engine share +│ │ ├── dom.ts # Reads the marks, reveals the marked content, leaves the page for a variant +│ │ ├── engine.ts # Assignment check, pageview hold, redirect or reveal +│ │ ├── markup.ts # experimentMarkup: attributes, hiding rule and boot script for a page +│ │ ├── models.ts # isUserIncluded response, stored assignments, ExperimentBootState +│ │ ├── plugin.ts # Analytics.js plugin: adds context.experiments +│ │ └── store.ts # localStorage and sessionStorage state +│ ├── react/ # DotCMSExperiment, the React adapter (the only place React is imported) +│ └── standalone/ # config.ts: the events config, read from the script tag dotCMS injects +├── rollup.config.cjs # Build, the same setup as @dotcms/client +├── vite.config.mts # Vitest config +├── vite.standalone.config.mts # The IIFE build: ca.min.js +├── project.json, package.json, tsconfig*.json, eslint.config.mjs +└── README.md +``` + +### What `init` Does + +Everything a renderer or the experiment needs happens before `init` returns; Analytics.js loads afterwards. + +1. Claims the config (`activeConfigKey`), so a second call returns at once. +2. Creates the experiments engine and starts its `isUserIncluded` check when one is due, or reveals the marked content when `experiments: false`. +3. Sets `window.__dotAnalyticsActive__` and sends `dotcms:analytics:ready`, so the page renderers print the contentlet attributes before they hydrate, and sets `window.dotEvents`. +4. Loads Analytics.js with `import('analytics')`. The module reads the time zone with `Intl.DateTimeFormat` when it is evaluated, so a static import put that work in the task that starts the app. Calls wait in `pendingCalls` meanwhile. +5. Once it loads, builds the instance, replays the waiting calls and starts the automatic pageviews. + +### The Analytics.js Instance + +`Analytics({ app: 'dotEvents', storage })` runs these plugins, in this order. The order matters: each plugin reads what the ones before it wrote. + +1. **dot-events-identity** (`pipeline/identity/plugin.ts`): creates `context` with `site_auth`, `session_id`, `user_id` and `device`, and tracks session activity. +2. **dot-events-experiments** (`experiments/plugin.ts`): adds the session's `context.experiments` in `pageStart` and `trackStart`. Left out with `experiments: false`. +3. **dot-events-impressions** and **dot-events-clicks** (`impressions/plugin.ts`, `clicks/plugin.ts`): only added with `impressions` or `clicks`. +4. **dot-events-enricher** (`pipeline/enricher/plugin.ts`): adds page, UTM and custom data. Its hooks are keyed with the sender's name (`page:`, `track:`), so both plugins read it from `SENDER_PLUGIN_NAME`: renaming one alone would leave events without page data, and the sender would drop them. +5. **dot-events-sender** (`pipeline/sender/plugin.ts`): builds the events and sends them through the queue to `/api/v1/analytics/content/event`. + +`storage` is `createMemoryStorage()`. Analytics.js keeps an anonymous id (`__anon_id`) that nothing here reads, and its default storage (`@analytics/storage-utils`) falls back to a cookie when localStorage is unavailable. In memory, nothing of Analytics.js's reaches localStorage, sessionStorage or a cookie; checked in Chrome with localStorage blocked. Its types leave `storage` out of the config, which the code works around with an intersection type. + +The queue sends batches of up to 15 events, every 5 s, and flushes everything with `keepalive` when the page is hidden (not on SPA navigation, where it persists the queue to sessionStorage instead). A batch carries one `context`: the one of the last event queued. + +### Pageview Flow on an Experiment Page + +A returning visitor's variant is decided by the boot script the page prints, while the HTML is parsed, from what the engine stored on an earlier page. A new visitor's is decided by the engine, once it loads. Both run the same function, `decideVariant` (`experiments/decision.ts`): the engine imports it, and the boot script receives it printed next to itself. It is pure and reads only its input: the experiment, the rendered variant, the URL, the stored assignments and whether experiments are off. It returns one of: + +- `unknown`: nothing is stored about the experiment; dotCMS has to be asked; +- `excluded`: experiments are off, or the visitor was evaluated and is not in this one; +- `assigned`: the server rendered the visitor's variant; +- `ignored`: the visitor has another variant and the URL already asks for it, so the route does not pass `variantName` to its page request; loading the URL again would loop; +- `redirect`: the visitor has another variant, at `url` (the same URL with `?variantName=`, or without the parameter for `DEFAULT`). + +The rest of the contract between the two (the attributes, the hiding rule, the timeouts, the storage keys and `ExperimentBootState`) lives in `experiments/constants.ts` and `experiments/models.ts`. + +1. On a page that runs an experiment, `DotCMSExperiment`, or a page that prints `experimentMarkup`, wraps what the experiment varies in an element marked `data-dot-experiment=""` and `data-dot-variant=""`. It prints before that element the rule that hides it (`HIDING_RULE`, which shows it on its own after `DECISION_TIMEOUT_MS`, 3 s) and the boot script (`buildExperimentBootScript`). On any other page it prints nothing. +2. The boot script (`dotcmsExperimentBoot`) runs before the app's scripts and never calls dotCMS. Inside UVE it shows the content. Otherwise it runs `decideVariant` on what is stored: on `unknown` it does nothing, so the content stays hidden and the engine decides once it loads; on `redirect` it calls `window.stop()` and `location.replace` to the variant's URL; on anything else it shows the content. +3. When it replaces the page, it leaves `ExperimentBootState` (`redirectedTo`, the variant's URL) on `window.__dotEventsExperimentBoot`. The page being left may still run its app until the variant's document arrives, so for 5 s it holds every new `fetch` of that page except `keepalive` ones. +4. On DOMContentLoaded, `sendPageView` asks the engine to `decide()` before it calls `instance.page()`. The engine answers `redirected` at once when the boot script is replacing the page, so it neither redirects again nor sends that page's pageview. +5. Otherwise `decide()` reads the marks. With no marks the pageview goes out at once. With marks, it runs `decideVariant` on its stored assignments; on a page the script already showed, it reaches the same decision from the same storage. On `unknown` it holds the pageview and waits for `isUserIncluded`, up to `experiments.timeout` (3000 ms by default): when the wait times out it reveals the content and sends the pageview, and when dotCMS answers it decides again. Then: + - `redirect`: it leaves the page with `leavePageFor` (`window.stop()`, `location.replace`, and the same 5 s hold of the page's new requests) and drops the pageview; the variant's page sends its own. On a first visit the page has hydrated by then, and Next would otherwise prefetch every visible link while the variant renders; + - `assigned`: it adds the experiment to the session's `context.experiments`; + - `ignored`: it warns that the route ignores `variantName`; + - `excluded`: nothing more. + + In every case but `redirect`, it reveals the content and sends the pageview. +6. The content is revealed by appending `:root [data-dot-experiment=""]{visibility:visible !important;animation:none !important}` to ` +// +//
…the content the experiment varies…
+``` + +Either way, an experiment page needs two more things: + +- its route passes the URL's `variantName` to the page request, or the variant's URL shows the original; +- it renders on every request: in Next.js, reading `searchParams` does that. A page prerendered at build time cannot run an experiment. + +With `debug: true` the SDK warns when an experiment runs on a page that prints neither, and when a route ignores `variantName`. + +## Configuration + +| Option | Default | What it does | +| -------------- | ----------- | -------------------------------------------------------------------------------------------------------------- | +| `dotcmsUrl` | required | The dotCMS origin, the same value `createDotCMSClient` takes | +| `siteAuth` | required | The Site Auth from the Content Analytics app | +| `autoPageView` | `true` | A pageview on load and on every History change | +| `experiments` | `true` | `isUserIncluded`, the pageview hold, the redirect and `context.experiments`; `{ timeout }` sets how long a new visitor's pageview waits for the assignment (3000 ms) | +| `impressions` | `false` | Content impressions; an object sets the threshold, dwell time and limits | +| `clicks` | `false` | Content clicks | +| `queue` | batching on | `false` sends each event at once; an object sets the batch size and interval | +| `debug` | `false` | Logs what the SDK does to the console | +| `logLevel` | | Minimum level for the analytics core's logs | +| `onError` | | Called when something fails where the page cannot see it (below) | + +## Errors + +Nothing the SDK sends is retried, and nothing it fails at throws into the page. `onError` receives what failed, with a `code`: + +| Code | When | +| ------------- | ---------------------------------------------------------------------------------------------------- | +| `REJECTED` | dotCMS answered an events request with an error, or accepted it but failed some of its events: `status` and `detail` say why | +| `NETWORK` | An events request got no answer | +| `EXPERIMENTS` | `isUserIncluded` failed; a 403 means experiments are off for the site, and the SDK stops asking for a day | + +An error that `onError` throws is ignored. + +## What It Stores + +No cookies. Everything carries the `dot_events_` prefix: + +| Key | Storage | Contents | +| ---------------------------------- | -------------- | --------------------------------------------------------------- | +| `dot_events_user_id` | localStorage | The visitor's id | +| `dot_events_experiments` | localStorage | The visitor's experiment assignments | +| `dot_events_experiments_off_until` | localStorage | Set when dotCMS answers 403: experiments stay off for a day | +| `dot_events_session_id` | sessionStorage | The session's id | +| `dot_events_tab_id` | sessionStorage | The tab's id | +| `dot_events_queue_` | sessionStorage | Events not sent yet, kept across a page navigation | +| `dot_events_experiments_checked` | sessionStorage | This tab already asked for the visitor's assignments | +| `dot_events_session_experiments` | sessionStorage | The experiments the session's events carry | diff --git a/core-web/libs/sdk/events/eslint.config.mjs b/core-web/libs/sdk/events/eslint.config.mjs new file mode 100644 index 000000000000..a5ff06862b29 --- /dev/null +++ b/core-web/libs/sdk/events/eslint.config.mjs @@ -0,0 +1,273 @@ +import baseConfig from '../../../eslint.config.mjs'; + +// The package's layers, enforced here instead of only described in CLAUDE.md. A file matches one +// block below: flat config lets the last block that sets a rule replace the earlier ones. +const SPECS = ['**/*.spec.ts', '**/*.spec.tsx']; +const NO_REACT = { + paths: [ + { name: 'react', message: 'React stays in src/lib/react, behind the ./react entry.' }, + { name: 'react-dom', message: 'React stays in src/lib/react, behind the ./react entry.' } + ], + group: ['react/*', 'react-dom/*'] +}; +// The capabilities built on the pipeline, which the pipeline never imports +const CAPABILITIES = [ + '**/contentlets/**', + '**/impressions/**', + '**/clicks/**', + '**/experiments/**', + '**/react/**' +]; +const PIPELINE_MESSAGE = + 'pipeline/ is what every event goes through: it imports nothing from the capabilities or the events layer.'; +const restrict = (patterns, allowReact = false) => [ + 'error', + { + paths: allowReact ? [] : NO_REACT.paths, + patterns: [ + ...patterns, + ...(allowReact + ? [] + : [ + { + group: NO_REACT.group, + message: 'React stays in src/lib/react, behind the ./react entry.' + } + ]) + ] + } +]; + +export default [ + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {} + }, + { + files: ['**/*.ts', '**/*.tsx'], + rules: { + // verbatimModuleSyntax erases an import only when it says `import type`, so a module + // that provides both values and types is imported twice, on purpose. + 'no-duplicate-imports': ['error', { allowSeparateTypeImports: true }], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'separate-type-imports' } + ] + } + }, + { + files: ['**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {} + }, + { + // The pipeline every event goes through: it knows nothing about the capabilities built + // on it. Files sit one or two folders below src/lib, and `../models` is the public types + // from the first and the pipeline's own from the second, hence two blocks. + files: ['src/lib/pipeline/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { group: [...CAPABILITIES, '../events', '../models'], message: PIPELINE_MESSAGE } + ]) + } + }, + { + files: ['src/lib/pipeline/*/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + group: [...CAPABILITIES, '../../events', '../../models'], + message: PIPELINE_MESSAGE + } + ]) + } + }, + { + // What the content trackers share: it builds on the pipeline only + files: ['src/lib/contentlets/**/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + group: [ + '**/impressions/**', + '**/clicks/**', + '**/experiments/**', + '**/react/**', + '../events', + '../models' + ], + message: 'contentlets/ builds on pipeline/ only: the trackers build on it.' + } + ]) + } + }, + { + files: ['src/lib/impressions/**/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + group: [ + '**/clicks/**', + '**/experiments/**', + '**/react/**', + '../events', + '../models' + ], + message: 'impressions/ builds on pipeline/ and contentlets/ only.' + } + ]) + } + }, + { + files: ['src/lib/clicks/**/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + group: [ + '**/impressions/**', + '**/experiments/**', + '**/react/**', + '../events', + '../models' + ], + message: 'clicks/ builds on pipeline/ and contentlets/ only.' + } + ]) + } + }, + { + files: ['src/lib/experiments/**/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + // A regex, because a gitignore negation cannot re-include a file whose + // folder an earlier pattern already excluded + regex: '^(?!.*/contentlets/constants$).*/contentlets/', + message: 'experiments/ takes only the rescan event from contentlets/.' + }, + { + group: ['**/impressions/**', '**/clicks/**', '**/react/**', '../events'], + message: + 'experiments/ builds on pipeline/: the events layer and the React adapter build on it.' + } + ]) + } + }, + { + // The adapter brings the markup, never the engine: that loads with events.init + files: ['src/lib/react/**/*.ts', 'src/lib/react/**/*.tsx'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict( + [ + { + group: [ + '**/pipeline/**', + '**/contentlets/**', + '**/impressions/**', + '**/clicks/**', + '../experiments/engine', + '../experiments/api', + '../experiments/store', + '../experiments/plugin', + '../events' + ], + message: + 'The React adapter imports the markup only: no engine, pipeline or trackers.' + } + ], + true + ) + } + }, + { + // The script tag config of the IIFE: it reads attributes and knows the public types only + files: ['src/lib/standalone/**/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + group: [...CAPABILITIES, '**/pipeline/**', '../events'], + message: 'standalone/ reads the script tag: it imports the public types only.' + } + ]) + } + }, + { + // The IIFE dotCMS injects: the events object and the script tag config, nothing else + files: ['src/standalone.ts'], + rules: { + 'no-restricted-imports': restrict([ + { + group: [ + './lib/pipeline/**', + './lib/contentlets/**', + './lib/impressions/**', + './lib/clicks/**', + './lib/experiments/**', + './lib/react/**' + ], + message: + 'The standalone script starts events from its script tag: it imports ./lib/events and ./lib/standalone only.' + } + ]) + } + }, + { + // Every page loads the root entry: it must not reference the boot script builder + files: ['src/index.ts'], + rules: { + 'no-restricted-imports': restrict([ + { + group: [ + './lib/experiments/markup', + './lib/experiments/boot', + './lib/react/**', + './lib/standalone/**' + ], + message: + 'The root entry does not re-export the markup: some bundlers keep a re-exported module in the chunk every page loads.' + } + ]) + } + }, + { + files: ['src/markup.ts'], + rules: { + 'no-restricted-imports': restrict([ + { + group: [ + './lib/events', + './lib/experiments/engine', + './lib/pipeline/**', + './lib/contentlets/**', + './lib/impressions/**', + './lib/clicks/**', + './lib/react/**' + ], + message: './markup is framework-free and holds no engine.' + } + ]) + } + }, + { + files: ['src/lib/*.ts'], + ignores: SPECS, + rules: { + 'no-restricted-imports': restrict([ + { + group: ['./react/**'], + message: 'React stays in src/lib/react, behind the ./react entry.' + } + ]) + } + } +]; diff --git a/core-web/libs/sdk/events/package.json b/core-web/libs/sdk/events/package.json new file mode 100644 index 000000000000..8191d6e96e21 --- /dev/null +++ b/core-web/libs/sdk/events/package.json @@ -0,0 +1,63 @@ +{ + "name": "@dotcms/events", + "version": "0.0.1-beta.0", + "description": "Official JavaScript library for dotCMS events: pageviews, conversions, content clicks and impressions, and experiments.", + "repository": { + "type": "git", + "url": "git+https://github.com/dotCMS/core.git#main" + }, + "peerDependencies": { + "@dotcms/uve": "0.0.0", + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "dependencies": { + "@analytics/core": "^0.13.0", + "@analytics/queue-utils": "^0.1.3", + "@analytics/router-utils": "^0.1.1", + "@analytics/storage-utils": "^0.4.0", + "analytics": "^0.8.0" + }, + "devDependencies": { + "@dotcms/types": "latest" + }, + "keywords": [ + "dotCMS", + "CMS", + "Content Management", + "Analytics", + "Experiments", + "Events", + "Tracking" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./markup": "./src/markup.ts", + "./react": "./src/react.ts" + }, + "typesVersions": { + "*": { + ".": [ + "./src/index.d.ts" + ], + "markup": [ + "./src/markup.d.ts" + ], + "react": [ + "./src/react.d.ts" + ] + } + }, + "author": "dotcms ", + "license": "MIT", + "bugs": { + "url": "https://github.com/dotCMS/core/issues" + }, + "homepage": "https://github.com/dotCMS/core/tree/main/core-web/libs/sdk/events/README.md", + "sideEffects": false +} diff --git a/core-web/libs/sdk/events/project.json b/core-web/libs/sdk/events/project.json new file mode 100644 index 000000000000..666a21e32324 --- /dev/null +++ b/core-web/libs/sdk/events/project.json @@ -0,0 +1,51 @@ +{ + "name": "sdk-events", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/sdk/events/src", + "projectType": "library", + "tags": [], + "implicitDependencies": ["sdk-types"], + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "inputs": [ + "production", + "^production", + { + "externalDependencies": ["rollup"] + } + ] + }, + "typecheck": { + "dependsOn": ["^build"] + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/libs/sdk/events" + } + }, + "lint": { + "options": { + "args": ["**/*.ts"] + } + }, + "build:standalone": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": ["^build"], + "inputs": [ + "production", + "^production", + { + "externalDependencies": ["vite"] + } + ], + "outputs": ["{workspaceRoot}/dist/libs/sdk/events-standalone"], + "options": { + "cwd": "libs/sdk/events", + "command": "vite build --config vite.standalone.config.mts" + } + } + } +} diff --git a/core-web/libs/sdk/events/rollup.config.cjs b/core-web/libs/sdk/events/rollup.config.cjs new file mode 100644 index 000000000000..c81e4f192048 --- /dev/null +++ b/core-web/libs/sdk/events/rollup.config.cjs @@ -0,0 +1,40 @@ +const { withNx } = require('@nx/rollup/with-nx'); + +const { typesFirstPlugin } = require('../../../tools/rollup/types-first.cjs'); + +// Same build as @dotcms/client: ESM and CJS, compiled with tsc, exports field generated by +// Nx. Three entries: the root one (events and its config type), ./markup (experimentMarkup) +// and ./react (the React adapter). The root entry shares only the constants with the other +// two, so pages that load the engine never download the boot script builder. React stays +// external: it is an optional peer. +const options = { + format: ['esm', 'cjs'], + compiler: 'tsc', + generateExportsField: true, + outputPath: '../../../dist/libs/sdk/events', + assets: [ + { + input: 'libs/sdk/events', + output: '.', + glob: '*.md' + } + ], + main: './src/index.ts', + additionalEntryPoints: ['./src/markup.ts', './src/react.ts'], + tsConfig: './tsconfig.lib.json' +}; + +const config = withNx(options, { + // Provide additional rollup configuration here. See: https://rollupjs.org/configuration-options +}); + +// Nx writes `module` before `types`, and conditions are order-sensitive, so TypeScript never +// reaches the declarations. Reorders keys only — every target is left as Nx set it. +const outputDir = Array.isArray(config.output) ? config.output[0]?.dir : config.output?.dir; + +config.plugins = [ + ...(Array.isArray(config.plugins) ? config.plugins : []), + typesFirstPlugin(outputDir) +]; + +module.exports = config; diff --git a/core-web/libs/sdk/events/src/index.ts b/core-web/libs/sdk/events/src/index.ts new file mode 100644 index 000000000000..62e14c8c7d47 --- /dev/null +++ b/core-web/libs/sdk/events/src/index.ts @@ -0,0 +1,20 @@ +/** + * `@dotcms/events`: the `events` object and the types its methods and options use. + * + * Every page loads this entry, so it holds the SDK and no markup: what a page prints so its + * experiment runs comes from `./markup` and `./react`. + */ + +export { events } from './lib/events'; +export type { + DotCMSEvents, + DotCMSEventsConfig, + DotCMSEventsError, + DotCMSEventsErrorCode, + DotCMSEventsExperimentsConfig, + DotCMSEventsImpressionsConfig, + DotCMSEventsJsonObject, + DotCMSEventsJsonValue, + DotCMSEventsLogLevel, + DotCMSEventsQueueConfig +} from './lib/models'; diff --git a/core-web/libs/sdk/events/src/lib/clicks/constants.ts b/core-web/libs/sdk/events/src/lib/clicks/constants.ts new file mode 100644 index 000000000000..32e14edd3633 --- /dev/null +++ b/core-web/libs/sdk/events/src/lib/clicks/constants.ts @@ -0,0 +1,20 @@ +/* + * Constants of the click tracker. + */ + +/** + * Event type for content clicks + * Must match DotCMSPredefinedEventType.CONTENT_CLICK + */ +export const CLICK_EVENT_TYPE = 'content_click'; + +/** + * Default debounce time in milliseconds for clicks + */ +export const DEFAULT_CLICK_THROTTLE_MS = 300; + +/** + * CSS selector for clickable elements to track + * Only clicks on and