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..f2e2b63e9397 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 { dotEvents } from '@dotcms/events';\nexport { dotEvents };\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..291dd3231c1a --- /dev/null +++ b/core-web/libs/sdk/events/CLAUDE.md @@ -0,0 +1,181 @@ +# 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 replaces `@dotcms/analytics` and `@dotcms/experiments`, and is greenfield: it sends only what dotCMS accepts today. + +One object, `dotEvents`, configured by one `init` call, runs a single Analytics.js instance. The core is framework-free; `DotCMSExperiment` (`./react`) and `experimentMarkup` (`./markup`) print what a page needs to run its experiment, and traditional (VTL) pages get the SDK as `ca.min.js`. Status: prototype, not released yet. + +How apps use it, every option and what it stores are in [README.md](README.md). This file covers what you need to change the code safely. + +## 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 +pnpm nx test sdk-events -- src/lib/impressions/tracker.spec.ts # one spec file + +# Vitest does not type-check: this must report no errors +./node_modules/.bin/tsc -p libs/sdk/events/tsconfig.spec.json --noEmit + +# Size, publint and declared-dependency checks on the built package (builds every SDK first) +pnpm nx test sdk-bundle-budgets +./node_modules/.bin/tsx libs/sdk/bundle-budgets/src/measure.ts # current gzip size of each probe + +# ca.min.js, the IIFE for traditional pages, in dist/libs/sdk/events-standalone +pnpm nx run sdk-events:build:standalone +``` + +`npx` fails in this repo with `EBADDEVENGINES` (the root `devEngines` requires pnpm): run binaries from `node_modules/.bin` or through `pnpm exec`. + +## Architecture Overview + +### Project Structure + +``` +src/ +├── index.ts # Public entry: dotEvents and the types its methods, options and errors use +├── markup.ts # Public entry ./markup: experimentMarkup +├── react.ts # Public entry ./react: DotCMSExperiment +├── standalone.ts # The IIFE entry (ca.min.js), not an export +└── lib/ + ├── events.ts # dotEvents: init, conversion, pageView, automatic pageviews + ├── models.ts # Public types (DotCMSEvents*) + ├── pipeline/ # What every event goes through: identity, enricher, sender (queue, http), navigation + ├── contentlets/ # What the two content trackers share + ├── impressions/ # IntersectionObserver + dwell time + ├── clicks/ # One capture listener on the document + ├── experiments/ # The engine, the boot script, decideVariant, the store + ├── react/ # DotCMSExperiment: the only place React is imported + └── standalone/ # Reads the config from the script tag dotCMS prints +``` + +### What `init` Does + +1. Claims the config, so a second call with the same config returns at once (another config is ignored with a warning). +2. Creates the experiments engine and starts its `isUserIncluded` check, or reveals the marked content when `experiments: false`. +3. Sets `window.__dotAnalyticsActive__` and sends `dotcms:analytics:ready`, so the React, Angular and Vue renderers print the contentlet attributes; sets `window.dotEvents`. +4. Loads Analytics.js with `import('analytics')`, kept out of the app's startup task. Calls made meanwhile wait (up to 50) and run once it loads. +5. Builds the instance, replays those calls and starts the automatic pageviews. + +On the server and inside the UVE editor every call is a no-op. + +### Plugin Order + +The Analytics.js plugins run in this order, and each reads what the earlier ones wrote: + +1. `dot-events-identity`: creates `context` (site, session, user, device) and tracks session activity. +2. `dot-events-experiments`: adds `context.experiments`; left out with `experiments: false`. +3. `dot-events-impressions` and `dot-events-clicks`: only when turned on. +4. `dot-events-enricher`: page, UTM and custom data. Its hooks are keyed with the sender's name, so both read it from `SENDER_PLUGIN_NAME`: renaming one alone leaves events without page data, and the sender drops them. +5. `dot-events-sender`: builds the dotCMS events and sends them through the queue. + +Analytics.js gets `createMemoryStorage()`: its default storage falls back to a cookie, and nothing of Analytics.js's may reach storage or cookies. + +### Queue and Page Lifecycle + +- Batches of up to 15 events, every 5 s. Each event keeps the context it was created with, and a batch goes out as one request per context (site, session, visitor, experiments). +- On a hidden page or `pagehide`, the queue sends everything with `keepalive`. On an SPA navigation it persists the queue to sessionStorage instead. +- A page kept in the back/forward cache comes back as it was, so the trackers clean up only on the `pagehide` that discards the page (`onPageDiscard`). `beforeunload` is not used: it fires before the page goes into that cache too. +- When the browser restores such a page (`onPageRestore`), it counts as a new view: a pageview, and the impressions count again. A restore is not a navigation: `onNavigation` reports nothing and the engine's navigation count stays. + +### Experiments + +- The contract (attributes, hiding rule, timeouts, storage keys, `ExperimentBootState`) lives in `experiments/constants.ts` and `experiments/models.ts`, and the decision in `decideVariant` (`decision.ts`). The boot script and the engine both run it, so change the decision there only. +- The boot script decides returning visitors while the HTML is parsed, from what is stored; the engine decides new visitors once it loads, waiting for `isUserIncluded` up to `experiments.timeout`. That wait is capped at 3 s, the time the hiding rule keeps the content hidden on its own. +- `sendPageView` asks the engine to `decide()` before `instance.page()`, and drops the pageview when the page redirects to a variant or the visitor leaves during the wait. +- Content is revealed through a style rule keyed by the rendered variant, never by touching DOM nodes React owns. +- `context.experiments` goes only on the events of pages that run an experiment the visitor is in, with every experiment the visitor joined in the session. This is a product decision: the analytics service filters events by `experiments` before grouping them by session, so Reach Page and URL Parameter goals reached on other pages don't count until it attributes them by session. +- Traditional pages run in `contentlets` mode (`initEvents(config, 'contentlets')`): the experiment comes from the stored `isExperimentPage` rule and the variant from the contentlet wrappers' `data-dot-variant`. +- With `debug: true`, the engine warns about setup mistakes through `warn` (silent otherwise): an experiment page with no marks, a route that ignores `variantName`, a timeout above 3 s. + +### Traditional Pages + +- `ca.min.js` reads its config from the script tag dotCMS prints (`readScriptConfig` in `standalone/config.ts`, with the attribute names of `dotCMS/src/main/resources/ca/html/analytics_head.html`); `dotcmsUrl` is the page's origin. +- dotCMS still serves `@dotcms/analytics`'s build at `/ext/analytics/ca.min.js`: `core-web/pom.xml` copies `dist/libs/sdk/analytics-standalone` there. Pointing it at `dist/libs/sdk/events-standalone` switches dotCMS to this SDK (#37798, the backend changes that make it the only script). VTL code that calls `window.dotAnalytics` then needs `window.dotEvents`. + +### Content Trackers + +- The impression tracker observes contentlets that have a `data-dot-identifier`, are visible and have a size. It scans 100 ms after `init`, on DOM changes, when an identifier arrives, and on `dotcms:events:rescan`, and saves each contentlet's position in `data-dot-dom-index`. It forgets the page's impressions on a navigation and on a restore. +- The click tracker listens once, on the document, in the capture phase, and finds the clicked link or button and its contentlet with `closest`. It writes nothing to the contentlets. + +## Rules That Break Silently + +### Public API + +- Three entries: `.`, `./markup` and `./react`. No `./internal`, ever. Adding an export is an API decision. +- Each entry exports every type its public signatures use. Public types live in `src/lib/models.ts` with the `DotCMSEvents` prefix; never export the core's internal types. +- The root entry does not re-export `experimentMarkup`: a bundler that keeps the re-export ships the boot script builder to every page. +- The event payload and the config stay compatible with `@dotcms/analytics`. Conversions carry a name only, on purpose: dotCMS rejects their custom data. +- The SDK sends the four event types dotCMS accepts (`pageview`, `conversion`, `content_impression`, `content_click`) and nothing else; the sender throws on any other type. No custom-event API until the backend accepts one. + +### Names Other Code Reads + +- `__dotAnalyticsActive__` and `dotcms:analytics:ready` keep their names because `@dotcms/uve` and the React, Angular and Vue SDKs read them: rename them only together with `@dotcms/uve`. +- New storage keys use the `dot_events_` prefix, new globals the `dotEvents` name, console messages the `[dotCMS events]` prefix. No cookies. + +### Printed Functions + +`dotcmsExperimentBoot` (`boot.ts`) and `decideVariant` are printed into the page with `toString()`, so neither may reference anything outside itself: no module constants, no helpers the compiler adds (the `es2020` target needs none for what they use). `decision.spec.ts` and `boot.spec.ts` run them from their printed source and fail on an outside reference. + +### Layers + +One folder per capability, files named by their role (`plugin.ts`, `tracker.ts`, `engine.ts`, `store.ts`...), no barrels inside `src/lib`. Dependencies go one way: `pipeline/` imports nothing built on it, the content trackers never import each other, and React stays in `src/lib/react`. `eslint.config.mjs` enforces it with one `no-restricted-imports` block per folder; read it before moving code between folders. + +## TypeScript + +- Every strict option is on, on top of `strict` (including `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`), specs included. +- The package compiles against the built declarations of `@dotcms/uve` and `@dotcms/types`, so `nx typecheck sdk-events` builds them first. +- Private fields use `#` (they cost bytes at this target; see Performance); private methods keep the `private` keyword. Specs test behavior, not private state. +- Type-only imports use `import type`, in a separate statement. In library code, an index that may miss gets a real check, not `!`. +- Optional settings stay without `| undefined` in the public types: they are merged over defaults, where an explicit `undefined` would replace a default. + +## Build and Distribution + +- `rollup.config.cjs` uses `withNx`, like `@dotcms/client`: ESM and CJS, `generateExportsField`, `types` first in the exports. The build type-checks. +- The three entries share one chunk, `decision.esm.js`, so the engine never loads the markup builder. +- The `build` target exists because `nx.json` lists `libs/sdk/events` in the `@nx/rollup/plugin` include. `project.json` declares `implicitDependencies: ["sdk-types"]`, without which the build fails with TS6059. +- Only `README.md` is copied into the package. This file is for working on the repo and does not ship. + +## Dependencies + +- Runtime: `analytics`, `@analytics/queue-utils` and `@analytics/router-utils`, the packages the built code imports. `@analytics/core` and `@analytics/storage-utils` come with `analytics`. +- Peers: `@dotcms/uve`, with the `"0.0.0"` sentinel the SDK release replaces with the release version, and `react` (`>=18`), optional, for `./react` only. +- Next.js 16 or later is stated in the README but not declared as a peer: nothing imports `next`, and a peer would only make npm refuse to install on an older version. + +## Testing + +- Vitest, configured by hand in `vite.config.mts` to match what `tools/generate-vite-configs.mjs` emits, with `pool: 'forks'`: the core specs set `global.window = undefined` for SSR cases, which throws on `vmForks`. +- Specs sit next to their sources as `*.spec.ts`. `events.spec.ts` and `events.pageviews.spec.ts` replace Analytics.js with a fake instance. +- Run the `tsc` command above as well: Vitest does not type-check. + +## Performance + +- `events-init` (`import { dotEvents } from '@dotcms/events'` with its dependencies): 29,208 B gzip, against a 30,000 B ceiling; the `#` fields are 977 B of it. `events-react`: 1,496 B against 2,200, and it fails if the engine or Analytics.js comes along. Raise a ceiling only with a measurement and a reason. +- `ca.min.js`: 73,098 B raw, 25,653 B gzip, experiments included. +- `sideEffects: false`: importing the package does nothing until `init`. + +## Summary Checklist + +Do: + +- Run builds, tests and lint through `pnpm nx`, and the spec `tsc` too. +- Keep the public surface in the three entries, with public types in `src/lib/models.ts`. +- Change the experiment contract in `experiments/constants.ts` and `experiments/models.ts`, and the decision in `decideVariant`. +- Name storage keys `dot_events_*` and globals after `dotEvents`. +- Put new code in its capability's folder, and give a new capability a `plugin.ts` if it hooks into Analytics.js. +- Check the bundle budgets after adding code or a dependency. + +Don't: + +- Add an `./internal` export, re-export the core's types, or re-export `experimentMarkup` from the root. +- Import React outside `src/lib/react`, or anything that reaches React from `src/standalone.ts` (its build fails on purpose). +- Add an event type dotCMS does not accept, or let Analytics.js write to storage or cookies. +- Reference anything outside `dotcmsExperimentBoot` or `decideVariant`. +- Rename the sender plugin without `SENDER_PLUGIN_NAME`, or `__dotAnalyticsActive__` and `dotcms:analytics:ready` without `@dotcms/uve`. +- Modify DOM nodes React owns: reveal through the style rule. +- Put experiment code in `@dotcms/uve`, `@dotcms/react` or another SDK. diff --git a/core-web/libs/sdk/events/README.md b/core-web/libs/sdk/events/README.md new file mode 100644 index 000000000000..e0d25a5df8b5 --- /dev/null +++ b/core-web/libs/sdk/events/README.md @@ -0,0 +1,517 @@ +# dotCMS Events SDK + +The `@dotcms/events` SDK is the official dotCMS library for everything your pages report back to dotCMS: pageviews, conversions, content impressions, content clicks, and A/B testing experiments. You configure it once, and a single object, `dotEvents`, takes care of the rest. + +## Overview + +### When to Use It + +- Measuring how visitors use your dotCMS-powered pages: which pages they visit, which content they see, and what they click +- Recording conversions when a visitor completes a goal, such as a sign-up or a download +- Running the A/B testing experiments you create in dotCMS on a headless site +- Replacing `@dotcms/analytics` and `@dotcms/experiments` with a single package + +### Key Features + +- **Automatic pageviews**: on the first load, on every client-side navigation, and when the visitor comes back to a page with the browser's Back and Forward buttons +- **Content impressions**: records which contentlets the visitor actually saw, and for how long they had to be on screen +- **Content clicks**: records clicks on the links and buttons inside your contentlets +- **Conversions**: one call when a visitor completes a goal +- **Experiments without flicker**: sends each visitor to their variant, and keeps the content hidden for a moment so nobody sees the wrong one +- **One setup**: one `init` call configures everything, and every module of your app uses the same object +- **No cookies**: the SDK stores anonymous ids in the browser's storage, never in cookies +- **Safe in the editor**: inside the dotCMS Universal Visual Editor (UVE), nothing is sent + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Which SDK Version Should I Use?](#which-sdk-version-should-i-use) +- [Quick Start (Next.js)](#quick-start-nextjs) + - [Example Project](#example-project) +- [How It Works](#how-it-works) +- [Configuration](#configuration) +- [Usage](#usage) +- [API Reference](#api-reference) +- [Migrating from @dotcms/analytics and @dotcms/experiments](#migrating-from-dotcmsanalytics-and-dotcmsexperiments) +- [Under the Hood](#under-the-hood) +- [Troubleshooting](#troubleshooting) +- [Support](#support) +- [Contributing](#contributing) +- [Licensing](#licensing) + +## Installation + +Install the package via npm: + +```bash +npm install @dotcms/events +``` + +Or using Yarn: + +```bash +yarn add @dotcms/events +``` + +Requirements: + +- A dotCMS instance with the **Content Analytics** app configured for your site. You need its **Site Auth**. +- **Next.js 16 or later**, if you use Next.js. +- **React 18 or later**, for `DotCMSExperiment` (`@dotcms/events/react`). + +The package has three entry points: + +| Import | What it gives you | Use it in | +| ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------ | +| `@dotcms/events` | `dotEvents`: starts the SDK, records conversions and sends pageviews | Every app | +| `@dotcms/events/react` | `DotCMSExperiment`: runs a page's experiment on the content it wraps | React and Next.js pages that run experiments | +| `@dotcms/events/markup` | `experimentMarkup`: what a server-rendered page prints to run its experiment | Other frameworks, rendered on the server | + +## Which SDK Version Should I Use? + +dotCMS SDKs are published in lockstep with dotCMS itself: every `@dotcms/*` package ships +at the **exact same version number** as the dotCMS release it was built for (e.g. dotCMS +`26.7.14-1` → `@dotcms/client@26.7.14-1`, `@dotcms/react@26.7.14-1`, and so on). + +**Simple rule of thumb: use the SDK version that matches your dotCMS instance's version.** + +You don't have to upgrade the SDK every time dotCMS releases a new version (or vice versa). +Most releases don't change anything the SDKs rely on, so an older SDK usually keeps working +fine against a newer dotCMS instance. Occasionally, though, a release does include a real +breaking change — and if your SDK is older than that point, it will stop working correctly. + +You don't need to track this yourself: your dotCMS instance always knows the oldest SDK +version it still supports, and the SDK checks itself against it automatically. If you're +using an SDK that's too old, you'll see a clear warning in your console telling you to +upgrade. + +**Recommendation:** pin your SDKs to the same version as your dotCMS instance, and only bump +them when you upgrade dotCMS — or when the console tells you to. + +> **On an LTS release?** LTS releases don't currently get their own matching SDK version. +> Until that's addressed, use the SDK version published for the closest regular release at +> or before your LTS version. +> +> Want more background on how dotCMS releases and support windows work? See +> [Release & Support Lifecycle](https://dev.dotcms.com/docs/release-support-lifecycle). + +## Quick Start (Next.js) + +### 1. Add the environment variables + +Add these to your `.env.local` file: + +```bash +NEXT_PUBLIC_DOTCMS_HOST=https://your-dotcms-instance.com +NEXT_PUBLIC_DOTCMS_SITE_AUTH=your-site-auth +``` + +| Variable | Description | +| ------------------------------ | ---------------------------------------------------------------- | +| `NEXT_PUBLIC_DOTCMS_HOST` | The URL of your dotCMS instance | +| `NEXT_PUBLIC_DOTCMS_SITE_AUTH` | The Site Auth from the Content Analytics app in dotCMS | + +### 2. Start the SDK once + +Create `src/instrumentation-client.ts`. Next.js runs this file once in the browser, before your app becomes interactive: + +```ts +// src/instrumentation-client.ts +import { dotEvents } from '@dotcms/events'; + +dotEvents.init({ + dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_HOST!, + siteAuth: process.env.NEXT_PUBLIC_DOTCMS_SITE_AUTH!, + impressions: true, + clicks: true +}); +``` + +That's all pageviews, impressions, clicks and experiments need. Impressions and clicks are optional; pageviews and experiments are on by default. + +### 3. Record conversions + +Import the same object wherever a visitor completes a goal, and record the conversion once it succeeds: + +```tsx +'use client'; + +import { dotEvents } from '@dotcms/events'; + +export function SignupForm() { + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + await createAccount(); // your own code + + // Only after the goal is reached, never on the attempt + dotEvents.conversion('signup'); + }; + + return
{/* fields */}
; +} +``` + +### 4. Run experiments on your pages + +Wrap the content your experiments change in `DotCMSExperiment`, and pass the URL's `variantName` to the page request: + +```tsx +// src/app/[[...slug]]/page.tsx +import { DotCMSExperiment } from '@dotcms/events/react'; + +import { dotCMSClient } from '@/lib/dotCMSClient'; // your @dotcms/client instance + +interface PageProps { + params: Promise<{ slug?: string[] }>; + searchParams: Promise<{ variantName?: string }>; +} + +export default async function Page({ params, searchParams }: PageProps) { + const { slug } = await params; + const { variantName } = await searchParams; + const path = slug?.length ? `/${slug.join('/')}` : '/'; + + // variantName makes dotCMS render the visitor's variant + const pageContent = await dotCMSClient.page.get(path, { variantName }); + + return ( + + + + ); +} +``` + +`MyPage` is your view of the page, for example one that renders it with `DotCMSLayoutBody` from `@dotcms/react`. On a page that runs no experiment, `DotCMSExperiment` renders its children as they are, so you can wrap every page the same way. + +### Example Project + +The [Next.js experiments example](https://github.com/dotCMS/core/tree/main/examples/nextjs-experiments) runs on this package. It shows: + +- The `init` call in `instrumentation-client.ts`, with the configuration in one file +- Pageviews, impressions and clicks on every page +- Experiments on the home page and on a blog listing, including content that streams in +- A conversion from a contact form + +## How It Works + +### Pageviews + +A pageview is sent when the page first loads, on every client-side navigation, and when the visitor returns to a page with the Back or Forward button. Each one carries the page's URL, title and referrer, the campaign (UTM) parameters, and the visitor's device and screen. + +To send them yourself instead, set `autoPageView: false` and call [`dotEvents.pageView()`](#doteventspageviewdata). + +### Content Impressions + +With `impressions` on, the SDK records each contentlet the visitor actually sees: at least half of it in view, for at least 750 ms. Each contentlet counts once per page view, and nothing counts while the tab is hidden or the window is behind others. + +Impressions and clicks need the contentlets to carry dotCMS's attributes (the `dotcms-contentlet` class and its `data-dot-*` attributes). The dotCMS React, Angular and Vue SDKs add them on their own while this SDK is active, so pages rendered with `DotCMSLayoutBody` need nothing more. + +### Content Clicks + +With `clicks` on, the SDK records clicks on the links and buttons inside your contentlets: which contentlet, the element's text and link, and where the contentlet sits on the page. Repeated clicks on the same contentlet within 300 ms count once. + +### Conversions + +A conversion records that a visitor reached a goal. dotCMS stores its name and the page it happened on. Record it only once the goal is reached: a completed purchase, a finished download, a created account, not the click that started it. + +### Experiments + +Experiments are on by default. When a visitor opens a page that runs an experiment: + +1. The content the experiment changes stays hidden for a moment, so the visitor never sees the wrong variant. +2. The SDK asks dotCMS which variant the visitor gets, and remembers the answer in the browser. +3. If the page shows another variant, the SDK sends the visitor to theirs: the same URL with `?variantName=`. +4. Otherwise it shows the content. + +A returning visitor is sent to their variant right away, before the page finishes loading. If dotCMS takes more than 3 seconds to answer a new visitor, the original content shows, and the assignment applies from the next page on. + +Events sent from a page that runs an experiment the visitor is in carry the experiment and the visitor's variant, together with the other experiments the visitor joined during the same session. Events from other pages carry none. + +### How Events Are Sent + +Events are sent in batches: every 5 seconds, or as soon as 15 are waiting. When the visitor switches tabs, closes the tab or leaves the page, whatever is waiting goes out right away. Nothing is retried, and nothing the SDK does can throw an error into your page: failures go to [`onError`](#errors). + +### Visitors and Sessions + +Each visitor gets an anonymous id, kept in the browser's localStorage, and each visit a session id. A session ends after 30 minutes without activity, or at midnight (UTC). The SDK sets no cookies. + +### In the Universal Visual Editor + +Inside the dotCMS Universal Visual Editor, the SDK sends nothing, and experiment content shows as it is, so editors can work on the page. On the server, every call does nothing. + +## Configuration + +### Config Options + +| Option | Type | Required | Default | Description | +| -------------- | ---------------------------------------- | -------- | ------- | --------------------------------------------------------------------------- | +| `dotcmsUrl` | `string` | Yes | — | The URL of your dotCMS instance, the same value `createDotCMSClient` takes | +| `siteAuth` | `string` | Yes | — | The Site Auth from the Content Analytics app | +| `autoPageView` | `boolean` | No | `true` | Send pageviews automatically | +| `experiments` | `boolean \| DotCMSEventsExperimentsConfig` | No | `true` | Run experiments; `false` turns them off | +| `impressions` | `boolean \| DotCMSEventsImpressionsConfig` | No | `false` | Record content impressions | +| `clicks` | `boolean` | No | `false` | Record content clicks | +| `queue` | `boolean \| DotCMSEventsQueueConfig` | No | `true` | Send events in batches; `false` sends each one at once | +| `debug` | `boolean` | No | `false` | Log what the SDK does to the browser console | +| `logLevel` | `'debug' \| 'info' \| 'warn' \| 'error'` | No | — | The least important level the console shows | +| `onError` | `(error: DotCMSEventsError) => void` | No | — | Called when something fails that the page can't see | + +### Impressions + +`true` turns impressions on with the defaults. An object changes them: + +| Option | Default | Description | +| --------------------- | ------- | ------------------------------------------------------------------ | +| `visibilityThreshold` | `0.5` | How much of the contentlet must be in view, from `0` to `1` | +| `dwellMs` | `750` | How long, in milliseconds, it must stay in view | +| `maxNodes` | `100` | The most contentlets tracked on a page | + +```ts +dotEvents.init({ + dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_HOST!, + siteAuth: process.env.NEXT_PUBLIC_DOTCMS_SITE_AUTH!, + impressions: { visibilityThreshold: 0.7, dwellMs: 1000 } +}); +``` + +### Experiments + +| Option | Default | Description | +| --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `timeout` | `3000` | How long, in milliseconds, a new visitor's page waits for their variant before showing the original. 3000 is also the most: past it the content shows on its own | + +### Queue + +| Option | Default | Description | +| ---------------- | ------- | -------------------------------------------- | +| `eventBatchSize` | `15` | The most events in one batch | +| `flushInterval` | `5000` | How often, in milliseconds, a batch is sent | + +### Errors + +`onError` receives what failed, with a `code`: + +| Code | When | +| ------------- | ------------------------------------------------------------------------------------------------------------ | +| `REJECTED` | dotCMS refused an events request, or some of its events. `status` and `detail` say why | +| `NETWORK` | An events request got no answer | +| `EXPERIMENTS` | Asking dotCMS for the visitor's variant failed. A 403 means experiments are off for the site; the SDK stops asking for a day | + +```ts +dotEvents.init({ + dotcmsUrl: process.env.NEXT_PUBLIC_DOTCMS_HOST!, + siteAuth: process.env.NEXT_PUBLIC_DOTCMS_SITE_AUTH!, + onError: (error) => console.warn(`dotCMS events: ${error.code}`, error.message) +}); +``` + +## Usage + +### Next.js App Router + +Follow the [Quick Start](#quick-start-nextjs). An experiment page also needs to: + +- **Pass `variantName` to the page request.** Without it, the variant's URL shows the original content. +- **Render on every request.** Reading `searchParams` does that. A page built ahead of time can't run an experiment. +- **Use `DotCMSExperiment` in a server component when it can.** It then adds no JavaScript to the browser; in a client component it adds about 1.5 KB. + +Content that streams in, behind `` or `loading.tsx`, works too: the SDK decides it when it arrives. On a site with a nonce-based Content-Security-Policy, pass the nonce: ``. + +### Other React Apps + +Call `init` once, where your app starts, before it renders. `DotCMSExperiment` works the same way. + +### Other Frameworks + +`dotEvents` works with any framework. To run experiments, print what `experimentMarkup` returns in the HTML your server sends: + +```ts +import { experimentMarkup } from '@dotcms/events/markup'; + +const markup = experimentMarkup(pageAsset); // null when the page runs no experiment + +// +// +//
the content the experiment changes
+``` + +### Traditional Pages + +Pages dotCMS renders itself (VTL) don't need this package: dotCMS adds the tracking to them on its own. You turn it on and configure it in the Content Analytics app. + +### Plain Scripts + +After `init`, any script on the page can reach the same object as `window.dotEvents`: + +```html + +``` + +### Sending Pageviews Yourself + +With `autoPageView: false`, call `dotEvents.pageView()` on each page, optionally with your own data. Call it as well when the browser brings back a page the visitor returns to with the Back button, which it can do without loading it again: + +```ts +dotEvents.pageView({ campaign: 'spring' }); + +window.addEventListener('pageshow', (event) => { + if (event.persisted) { + dotEvents.pageView(); + } +}); +``` + +## API Reference + +### `dotEvents.init(config)` + +Starts the SDK with your [configuration](#config-options). Call it once, as early as possible. A second call with the same configuration does nothing; one with another configuration is ignored with a warning. Calls made before the SDK is ready wait and run once it is. + +### `dotEvents.conversion(name)` + +Records a conversion with its `name`. dotCMS records the name and the page; it accepts no other data. + +### `dotEvents.pageView(data?)` + +Sends a pageview, with optional custom data. Only needed with `autoPageView: false`. + +### `DotCMSExperiment` + +```tsx +import { DotCMSExperiment } from '@dotcms/events/react'; +``` + +| Prop | Type | Required | Description | +| ----------- | ----------- | -------- | -------------------------------------------------------------------- | +| `page` | page asset | Yes | The page asset, requested with the URL's `variantName` | +| `children` | `ReactNode` | Yes | What the experiment changes: the page's layout, or part of it | +| `nonce` | `string` | No | The nonce of a nonce-based Content-Security-Policy | +| `className` | `string` | No | A class for the element that wraps the content | + +### `experimentMarkup(page)` + +```ts +import { experimentMarkup } from '@dotcms/events/markup'; +``` + +Returns what a server-rendered page prints to run its experiment, or `null` when the page runs none: `style` and `script`, which go before the content, and `attributes`, for the element that wraps it. + +### Types + +Every type the options, methods and errors use comes from the main entry: + +```ts +import type { DotCMSEventsConfig, DotCMSEventsError } from '@dotcms/events'; +``` + +## Migrating from @dotcms/analytics and @dotcms/experiments + +| Before | With `@dotcms/events` | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `` in the root layout | `dotEvents.init({...})` in `src/instrumentation-client.ts` | +| `server` in the analytics config | `dotcmsUrl` | +| `useContentAnalytics(config).conversion(name, data)` | `dotEvents.conversion(name)`: dotCMS records the name only | +| `useContentAnalytics(config).pageView(data)` | `dotEvents.pageView(data)`, with `autoPageView: false` | +| `useContentAnalytics(config).track(name, data)` | Not available: dotCMS accepts pageviews, conversions, impressions and clicks only | +| `withExperiments(DotCMSLayoutBody, config)` | `` around the content | +| A separate experiments config, with its own key | The same `init` call: experiments are on by default | + +Visitor and session ids are stored under new names (`dot_events_*`), so visitors start with new ids after the switch. + +## Under the Hood + +### What It Stores + +No cookies. Everything carries the `dot_events_` prefix: + +| Key | Storage | Contents | +| ---------------------------------- | -------------- | ------------------------------------------------------------ | +| `dot_events_user_id` | localStorage | The visitor's anonymous id | +| `dot_events_experiments` | localStorage | The visitor's experiment assignments | +| `dot_events_experiments_off_until` | localStorage | Set when experiments are off for the site, 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 until the next page sends them | +| `dot_events_experiments_checked` | sessionStorage | This tab already asked for the visitor's assignments | +| `dot_events_session_experiments` | sessionStorage | The experiments the visitor joined during the session | + +### Endpoints + +| Request | What for | +| ------------------------------------------ | ----------------------------------------- | +| `POST {dotcmsUrl}/api/v1/analytics/content/event` | Every event | +| `POST {dotcmsUrl}/api/v1/experiments/isUserIncluded` | The visitor's experiment assignments | + +## Troubleshooting + +### Common Issues & Solutions + +| Symptom | Likely cause | What to do | +| ------------------------------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| No events in the Network tab | Missing `dotcmsUrl` or `siteAuth`, or you're inside the Universal Visual Editor | Check both values, test outside the editor, and turn on `debug: true` | +| Environment variables are empty | Next.js only exposes variables that start with `NEXT_PUBLIC_` to the browser | Rename them, and restart the dev server after changing `.env.local` | +| `onError` reports `REJECTED` | dotCMS refused the events | Check that `siteAuth` is the Site Auth of the same site, and read `detail` | +| No impressions | `impressions` is off, the contentlets lack dotCMS's attributes, or the tab is hidden | Turn it on, render with a dotCMS SDK, and keep the page in view while testing | +| No clicks | `clicks` is off, or the element isn't a link or button inside a contentlet | Turn it on, and click a link or button inside a contentlet | +| The variant never shows | The route doesn't pass `variantName` to the page request, or the page is built ahead of time | Pass `variantName`, and render the page on every request | +| The page reloads once on a first visit | Expected: the visitor is being sent to their variant | Nothing to do | +| The experiment content appears after 3 seconds | dotCMS took longer than 3 seconds to answer | The original shows; the visitor gets their variant from the next page on | +| Nothing about experiments in the Network tab | The experiment isn't running in dotCMS, or experiments are off | Check the experiment's status in dotCMS, and look for `EXPERIMENTS` in `onError` | + +### Debugging Tips + +1. **Turn on debug mode.** `debug: true` logs what the SDK does to the browser console, and warns about setup mistakes, such as a route that ignores `variantName`. +2. **Watch the Network tab.** Filter by `/api/v1/analytics/content/event` to see each batch of events, and by `isUserIncluded` to see the experiments check. +3. **Check the storage.** In DevTools > Application, look for the `dot_events_*` keys in localStorage and sessionStorage. Clearing them makes you a new visitor, with a new experiment assignment. + +### Still Having Issues? + +If you're still experiencing problems after trying these solutions: + +1. Search existing [GitHub issues](https://github.com/dotCMS/core/issues) +2. Ask questions on the [community forum](https://community.dotcms.com/) to engage with other users. +3. Create a new issue with: + - Detailed reproduction steps + - Environment information + - Error messages + - Code samples + +## Support + +We offer multiple channels to get help with the dotCMS Events SDK: + +- **GitHub Issues**: For bug reports and feature requests, please [open an issue](https://github.com/dotCMS/core/issues/new/choose) in the GitHub repository. +- **Community Forum**: Join our [community discussions](https://community.dotcms.com/) to ask questions and share solutions. +- **Stack Overflow**: Use the tag `dotcms-events` when posting questions. +- **Enterprise Support**: Enterprise customers can access premium support through the [dotCMS Support Portal](https://www.dotcms.com/support). + +When reporting issues, please include: + +- SDK version you're using +- Framework/library version (if applicable) +- Minimal reproduction steps +- Expected vs. actual behavior + +## Contributing + +GitHub pull requests are the preferred method to contribute code to dotCMS. We welcome contributions to the dotCMS Events SDK! If you'd like to contribute, please follow these steps: + +1. Fork the repository [dotCMS/core](https://github.com/dotCMS/core) +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +Please ensure your code follows the existing style and includes appropriate tests. + +## Licensing + +dotCMS is available under either the [Business Source License 1.1 (BSL)](https://www.dotcms.com/bsl) or a commercial license. + +Under the BSL, dotCMS can be used at no cost by individual developers, small businesses or agencies under $5M in total finances, and by larger organizations in non-production environments. Every BSL release automatically converts to GPL v3 four years after its release date. For full terms and FAQs, visit [dotcms.com/bsl](https://www.dotcms.com/bsl) and [dotcms.com/bsl-faq](https://www.dotcms.com/bsl-faq). + +Production use in larger organizations, along with access to managed cloud, SLAs, support, and enterprise capabilities, is available under a commercial license from dotCMS. For details on commercial plans, features, and support options, see [dotcms.com/pricing](https://www.dotcms.com/pricing). 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..51d2170fb39b --- /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 dotEvents.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..18bfa4f7cf6c --- /dev/null +++ b/core-web/libs/sdk/events/package.json @@ -0,0 +1,61 @@ +{ + "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/queue-utils": "^0.1.3", + "@analytics/router-utils": "^0.1.1", + "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..aee1fcc8ba63 --- /dev/null +++ b/core-web/libs/sdk/events/rollup.config.cjs @@ -0,0 +1,41 @@ +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', + // Only the README ships: CLAUDE.md is for working on this repo, not for the package's users + assets: [ + { + input: 'libs/sdk/events', + output: '.', + glob: 'README.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..3c5f9e180655 --- /dev/null +++ b/core-web/libs/sdk/events/src/index.ts @@ -0,0 +1,20 @@ +/** + * `@dotcms/events`: the `dotEvents` 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 { dotEvents } 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 '); + document.body.appendChild(element); + click(element.querySelector('button')!); + + expect(identifiers()).toEqual(['later']); + }); + + it('ignores clicks outside contentlets, and clicks in one that are not on a link or button', () => { + const outside = document.createElement('a'); + outside.href = '#outside'; + const element = contentlet('blog-1', '

Just text

View detail'); + document.body.append(outside, element); + tracker.initialize(); + + click(outside); + click(element.querySelector('p')!); + + expect(clicked).not.toHaveBeenCalled(); + }); + + it('counts a click whose own handler stops its propagation', () => { + const element = contentlet('blog-1'); + document.body.appendChild(element); + element.querySelector('a')!.addEventListener('click', (event) => event.stopPropagation()); + tracker.initialize(); + + click(element.querySelector('a')!); + + expect(identifiers()).toEqual(['blog-1']); + }); + + it('counts a click inside nested contentlets once, for the innermost', () => { + const outer = contentlet('outer', ''); + const inner = contentlet('inner'); + outer.appendChild(inner); + document.body.appendChild(outer); + tracker.initialize(); + + click(inner.querySelector('a')!); + + expect(identifiers()).toEqual(['inner']); + }); + + it('reports the position the contentlet has when it is clicked', () => { + const first = contentlet('first'); + const second = contentlet('second'); + document.body.append(first, second); + tracker.initialize(); + vi.advanceTimersByTime(1000); + + // A contentlet rendered later, before the clicked one + document.body.insertBefore(contentlet('inserted'), second); + click(second.querySelector('a')!); + + expect(payloads()[0]?.position.dom_index).toBe(2); + }); + + it('writes nothing to the contentlets', () => { + const element = contentlet('blog-1'); + document.body.appendChild(element); + const attributesBefore = element.getAttributeNames(); + tracker.initialize(); + vi.advanceTimersByTime(1000); + + click(element.querySelector('a')!); + + expect(element.getAttributeNames()).toEqual(attributesBefore); + }); + + it('throttles each contentlet on its own', () => { + const first = contentlet('first'); + const second = contentlet('second'); + document.body.append(first, second); + tracker.initialize(); + + click(first.querySelector('a')!); + click(first.querySelector('a')!); + click(second.querySelector('a')!); + vi.advanceTimersByTime(DEFAULT_CLICK_THROTTLE_MS + 1); + click(first.querySelector('a')!); + + expect(identifiers()).toEqual(['first', 'second', 'first']); + }); + + it('notifies every subscriber, and stops notifying one that unsubscribed', () => { + const element = contentlet('blog-1'); + document.body.appendChild(element); + const other = vi.fn(); + const subscription = tracker.onClick(other); + tracker.initialize(); + + click(element.querySelector('a')!); + subscription.unsubscribe(); + vi.advanceTimersByTime(DEFAULT_CLICK_THROTTLE_MS + 1); + click(element.querySelector('a')!); + + expect(clicked).toHaveBeenCalledTimes(2); + expect(other).toHaveBeenCalledTimes(1); + }); + + it('stops listening on cleanup', () => { + const element = contentlet('blog-1'); + document.body.appendChild(element); + const removeEventListener = vi.spyOn(document, 'removeEventListener'); + tracker.initialize(); + + tracker.cleanup(); + click(element.querySelector('a')!); + + expect(clicked).not.toHaveBeenCalled(); + expect(removeEventListener).toHaveBeenCalledWith('click', expect.any(Function), true); + + removeEventListener.mockRestore(); + }); + + it('does nothing without a document, as on the server', () => { + (coreUtils.isBrowser as Mock).mockReturnValue(false); + const addEventListener = vi.spyOn(document, 'addEventListener'); + + tracker.initialize(); + + expect(addEventListener).not.toHaveBeenCalledWith('click', expect.any(Function), true); + + addEventListener.mockRestore(); + }); +}); diff --git a/core-web/libs/sdk/events/src/lib/clicks/tracker.ts b/core-web/libs/sdk/events/src/lib/clicks/tracker.ts new file mode 100644 index 000000000000..fd577fa2f829 --- /dev/null +++ b/core-web/libs/sdk/events/src/lib/clicks/tracker.ts @@ -0,0 +1,124 @@ +import { CLICKABLE_ELEMENTS_SELECTOR, DEFAULT_CLICK_THROTTLE_MS } from './constants'; +import { handleContentletClick } from './utils'; + +import { CONTENTLET_CLASS } from '../contentlets/constants'; +import { createPluginLogger, isBrowser } from '../pipeline/utils'; + +import type { PipelineConfig, DotCMSContentClickPayload } from '../pipeline/models'; + +/** Callback function for click events */ +export type ClickCallback = (eventName: string, payload: DotCMSContentClickPayload) => void; + +/** Subscription object with unsubscribe method */ +export interface ClickSubscription { + unsubscribe: () => void; +} + +/** + * Tracks clicks on the links and buttons inside contentlets, through one click listener on the + * document, in the capture phase. A click finds its contentlet with `closest`, so: + * - nothing scans or observes the page, and a contentlet added at any time counts; + * - an app's `stopPropagation()` cannot hide a click from the tracker; + * - a click inside nested contentlets counts once, for the innermost; + * - clicks on the same contentlet within 300 ms count once. + * + * @example + * ```typescript + * const tracker = new DotCMSClickTracker(config); + * const subscription = tracker.onClick((eventName, payload) => { + * console.log('Click detected:', payload); + * }); + * tracker.initialize(); + * // Later: subscription.unsubscribe(); + * ``` + */ +export class DotCMSClickTracker { + // When each contentlet was last clicked: a click on one never throttles another + #lastClickAt = new WeakMap(); + #logger: ReturnType; + #subscribers = new Set(); + #listening = false; + + constructor(config: PipelineConfig) { + this.#logger = createPluginLogger('Click', config); + } + + /** + * Subscribe to click events + * @param callback - Function called when click is detected + * @returns Subscription object with unsubscribe method + */ + public onClick(callback: ClickCallback): ClickSubscription { + this.#subscribers.add(callback); + + return { + unsubscribe: () => { + this.#subscribers.delete(callback); + } + }; + } + + /** Starts listening for clicks; without a document, as on the server, it does nothing */ + public initialize(): void { + if (!isBrowser()) { + this.#logger.warn('No document, skipping'); + return; + } + + if (this.#listening) { + return; + } + + document.addEventListener('click', this.#handleClick, true); + this.#listening = true; + + this.#logger.info('Plugin initialized'); + } + + /** Reports a click on a link or button inside a contentlet, throttled per contentlet */ + readonly #handleClick = (event: MouseEvent): void => { + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + const contentlet = target + .closest(CLICKABLE_ELEMENTS_SELECTOR) + ?.closest(`.${CONTENTLET_CLASS}`); + + if (!contentlet) { + return; + } + + handleContentletClick( + event, + contentlet, + (eventName, payload) => { + const now = Date.now(); + + if (now - (this.#lastClickAt.get(contentlet) ?? 0) < DEFAULT_CLICK_THROTTLE_MS) { + return; + } + + this.#lastClickAt.set(contentlet, now); + this.#subscribers.forEach((callback) => callback(eventName, payload)); + this.#logger.info(`Fired click event for ${payload.content.identifier}`, payload); + }, + this.#logger + ); + }; + + /** + * Stops listening for clicks. Should be called when the plugin is disabled or the page is + * discarded. + */ + public cleanup(): void { + if (this.#listening) { + document.removeEventListener('click', this.#handleClick, true); + this.#listening = false; + } + + this.#logger.info('Click tracking cleaned up'); + } +} diff --git a/core-web/libs/sdk/events/src/lib/clicks/utils.spec.ts b/core-web/libs/sdk/events/src/lib/clicks/utils.spec.ts new file mode 100644 index 000000000000..b791470d15e0 --- /dev/null +++ b/core-web/libs/sdk/events/src/lib/clicks/utils.spec.ts @@ -0,0 +1,507 @@ +import { vi } from 'vitest'; + +import { CLICK_EVENT_TYPE } from './constants'; +import { handleContentletClick } from './utils'; + +import { CONTENTLET_CLASS } from '../contentlets/constants'; +import * as trackingUtils from '../contentlets/utils'; +import * as viewport from '../contentlets/viewport'; + +import type { DotCMSContentClickPayload } from '../pipeline/models'; +import type * as sharedUtils from '../pipeline/utils'; +import type { Mock } from 'vitest'; + +// Mock dependencies +vi.mock('../contentlets/utils'); +vi.mock('../contentlets/viewport'); + +describe('Click Utils', () => { + let mockLogger: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + time: vi.fn(), + timeEnd: vi.fn() + } as unknown as ReturnType; + + // Mock getViewportMetrics + (viewport.getViewportMetrics as Mock).mockReturnValue({ + offsetPercentage: 50 + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('handleContentletClick()', () => { + let trackCallback: Mock; + + beforeEach(() => { + trackCallback = vi.fn(); + + // The page's contentlets, as findContentlets finds them + (trackingUtils.findContentlets as Mock).mockImplementation(() => + Array.from(document.querySelectorAll(`.${CONTENTLET_CLASS}`)) + ); + + // Mock extractContentletData + (trackingUtils.extractContentletData as Mock).mockReturnValue({ + identifier: 'test-123', + inode: 'inode-456', + title: 'Test Content', + contentType: 'Blog' + }); + }); + + describe('Early returns - invalid clicks', () => { + it('should return early if click target is not or