[data-reveal]{opacity:1!important;transform:none!important}
Architecture
Marketing

Architecture

Data flow, tenancy, auth model, event contract, HTTP surface, snapshot and assignment contracts, and phasing.

How the module is built, and why. Reader-facing feature behaviour lives in usage/; this page is for whoever develops the module or integrates against its API.

Follow one visit through the system

A visitor opens /pricing, then submits signup. The tracker sends a pageview and a signup_submitted custom event. Ingestion validates the events, assigns them to a tenant and session, and updates the daily counters. The Overview reads those counters; a funnel reads the retained events to check their order.

That distinction explains a common surprise: after raw events expire, traffic totals can still exist while the old funnel journey can no longer be computed. Use Data flow for storage ownership, HTTP surface for endpoints, and the snapshot and assignment contracts only when integrating those features.

Data flow

client site (tracked website)          DMS admin (Vue 3 / Inertia)
        │  <script tracker.js>                 │
        ▼                                      ▼
POST /api/marketing/collect  ◄──── batched events (sendBeacon)
        │
        ├─► marketing_events              raw, thin, short retention (funnels/heatmaps)
        ├─► marketing_sessions            visit + acquisition context (retention cohorts)
        └─► marketing_website_statistics  daily rollups, one row per website-day (dashboards)

POST /api/marketing/snapshot ◄──── one serialized page rendering per sampled visit the
        │                          backend asked for (GET snapshot.js negotiation),
        │                          or the size of one too large to upload
        └─► marketing_page_snapshots      opt-in, masked, one per (website, path, layout)

Key choices

All inherited from what worked (and failed) in this codebase:

  • Rollups at write time; the overview never reads raw events. Heatmaps and funnel results are the deliberate exceptions: they query the bounded raw window, which is why marketing_events is kept at all. cms-audit died storing raw rows only; the daily-rollup idea comes from dms-api's RouteStatistics, with one structural departure: one row per website-day (_id = websiteId:day) instead of embedded day arrays, so the scalar counters are native server-side increments — concurrent batches, parallel instances included, add their deltas instead of overwriting each other (services/rollup-write.ts). Session quality rides the same increments: bounces are counted as transitions (the batch bringing a second pageview takes the bounce back) and duration as per-batch extensions, so bounce rate, average duration and pages/session all derive from scalars. A day also carries the top-N maps, the one part that stays a read-merge-write: dynamic keys with cap-based eviction, which no increment can express — exit pages lean on that merge to move a session's one exit count between pages. Past TOP_MAPS_RETENTION_DAYS (455: a year plus the 90 days of MAX_QUERY_PERIOD_DAYS) the hourly prune empties the maps and only the scalars keep the full statistics retention — nothing is lost that a dashboard could have read, the oldest read being a 90-day window compared with the previous year. A comparison reaching days whose maps were already emptied shows no delta rather than a false one. The dimensions themselves are declared once, in TOP_DIMENSIONS (src/types/statistics.ts), which drives the write, the trim and the read.
  • Exhaustive capture, no per-route opt-in. The tracker captures every page; sampling only applies to heatmap events (clicks/scrolls), at the rate the Settings page publishes.
  • Thin events. No headers, no bodies, no IP, no user agent in storage. The tracker reports an absolute URL, the server stores the path alone: query strings are dropped at ingestion (UTM extracted onto sessions) and the hostname is only used to tell an internal navigation from an acquisition referrer — a referrer matching the website's domain or any hostname the batch was tracked from is dropped, never counted as a source.
  • Page content only as opt-in snapshots. The one table holding page content is marketing_page_snapshots: a website must opt in, form values and marked elements are masked before anything leaves the visitor's browser, one capture per (website, path, layout) is kept under a retention of its own — see Snapshot contract and Pages & heatmaps. It exists because the heatmap needs a backdrop and most production sites refuse to be framed; it stays a static, masked rendering, never a recording.
  • Country without the IP. With geoipDatabasePath configured, the collect request's IP resolves to an ISO country code against a local MaxMind .mmdb file (services/geoip.ts) and the code lands on the session — the IP itself is still never stored, and no network call is made. Opt-in on purpose: the module ships no GeoIP data, so the operator who mounts a database (GeoLite2 under its EULA, or a commercial one) is the one accepting its licensing terms and owning the compliance analysis. Unconfigured, sessions carry no country and the overview hides the card.
  • Acquisition rolled up whole. All five utm_* parameters land on the session and each rolls up as its own dimension; topCampaigns additionally keys full (source, medium, campaign) triples — joined on a control-character separator the stats read splits back, so the composite never leaks past the storage layer. The channel grouping (direct / organic / social / email / referral / paid) is nothing but a mapping of (referrer domain, utm_medium, ad click-id parameter) resolved when the session's rollup increment fires (services/acquisition.ts) — never stored on the session, so the classifier can evolve without a migration. The other side of that choice: a change never reaches back. Sessions already rolled up keep the channel they got in the topChannels counters, and only sessions rolled up after the change classify by the new rules (the Email channel and the referral mediums arrived that way).
  • Sessions. A session groups a visitor's events until DEFAULT_SESSION_IDLE_TIMEOUT_MS (30 min) of inactivity; acquisition context (referrer, UTM, device triple) lives on the session so events stay thin. Only a pageview opens one, and it starts at that pageview's own timestamp, not at the batch's arrival. A batch without a pageview (a click or a scroll depth sent long after the page loaded) joins the visitor's last session even past the timeout — the gap counts as idle, not as visit time — and is dropped when the visitor has none. A pageview carrying campaign parameters (UTM or an ad click id) that differ from the open session's starts a new session, as in Google Analytics: acquisition is fixed at a session's start, and joining would lose the campaign.

Anonymous identity

The server computes visitorId = sha256(monthly salt + website + IP + UA) for each request (services/visitor-id.ts). It stores none of the raw identity inputs and sets no cookie. Rotating the salt monthly changes the visitor ID, so it also changes deterministic A/B assignments.

These measures reduce the personal data stored; they do not establish a legal consent exemption for your deployment. Assess the applicable rules, especially before enabling page snapshots or GeoIP. Session replay is not implemented; page snapshots are static captures enabled separately for each site.

Tenancy — structural, like the rest of the DMS

Analytics tables (marketing_events, marketing_sessions, marketing_website_statistics, marketing_page_snapshots, marketing_funnels), the members' console preferences (marketing_preferences, the website each member last selected) and the record of applied analytics imports (marketing_imports) live in the DMS dms-tenant schema — one database instance per tenant, the same model as dms core and dms-saas — so isolation is the instance boundary, not a tenantId filter someone can forget.

Two tables are global (dms-core schema) on purpose:

  • marketing_websites — the public collect endpoint carries no JWT and needs the websiteId → tenantId routing step to land the beacon in the right instance. This is the ONE surface still scoped by column (src/data-api/tenant-scope.ts).
  • marketing_settings — a deployment singleton.

Everything else reads its tenant instance via GetModel(Model, tenantId) / @TenantScopedModel.

Tenant lifecycle contract. Hook.TENANT_DELETED purges the tenant's events, sessions, page snapshots, rollups, funnels and member preferences, then its websites; Hook.USER_DELETED forgets a deleted account's website selection in each of its workspaces; Hook.TENANT_DATA_EXPORT contributes websites, funnels and rollups to the workspace export (src/hooks; events, sessions and page snapshots are excluded — retention-bounded captures, not the durable data a workspace owns). Registered in construct(), the dms-saas pattern.

Website lifecycle contract. DELETE /websites/:id purges the site's tracking data (events, sessions, rollup, page snapshots, applied imports) before removing the row (services/tenant-website.ts). Not just hygiene: the retention prune enumerates tenants through the websites table, so rows left behind by a tenant's LAST site would outlive every retention pass. The site's funnel definitions and their A/B splits are deleted too: without their website, they could no longer be listed or computed.

Retention. An hourly cron prunes raw events, page snapshots, sessions and rollup rows past their configured retention and empties the top maps of rollup rows past the readable window (src/services/prune.ts, scheduled in src/index.ts). Every instance runs the pass. Events have immutable timestamps, and rollup IDs encode an immutable day, so their range cleanup remains valid after concurrent writes. Clearing old top maps changes no scalar counters.

Sessions and snapshots can become active under the same ID. Their cleanup reads only expired IDs and dates, then calls atomicMutation with deleteIfEqual on the observed lastSeenAt or capturedAt. The adapter must compare the date and delete the scoped ID atomically: a predicate in a generic range-delete statement does not provide this guarantee on every backend. Date equality suffices for the fixed cutoff; no revision or general ABA guarantee is required. Only applied counts as deletion. unknown stops that cleanup with an error, without retry or an unconditional-delete fallback.

Integration prerequisite: the database interface and adapter must implement deleteIfEqual. Validation uses the published database interface, the MongoDB adapter 1.4.2 the package pins as a dev dependency and the DMS >=0.7.16 release resolved at the time, without source overlays. pnpm test loads the real modules through Antelope and exercises MongoDB with a disposable database (the first run downloads MongoDB unless MONGOMS_SYSTEM_BINARY is set). Marketing integration tests on PostgreSQL and RethinkDB remain pending; the MongoDB result is not a portability claim.

Auth model — two deliberate levels

Module pages (/modules/marketing/*) and the /api/marketing/tables/* DataControllers behind them are platform-owner-only (@AuthOwnerOnly) — the DMS convention that module surfaces are owner administration spaces. The REST reads and mutations (/api/marketing/blocks/*, /context, /stats/*, /websites, /funnels/*) are tenant-scoped (@AuthTenantMember / @AuthTenantOwner): they answer for the caller's tenant claim, so the day a marketing surface moves to a tenant-reachable category, the API is already right and only the pages move. Settings stay owner-only at both levels — the row is deployment-global and drives cross-tenant prunes.

Every module block carries .meta() with a translated name, description and icon (page.marketing.blocks.<key> in the frontend catalogs), and so do the stock blocks and containers the pages declare (blockMeta() in src/pages/blocks.ts), so a block's permission reads as a name in the roles tree, never as a component name. Layout rows (GridRow) carry none.

Event contract

POST /api/marketing/collect — public, batched (≤ 25), zod-validated. Public does not mean unguarded; three checks stand between the endpoint and the data, each scoped to the misuse it can actually see:

  • Browser origin. The Origin (fallback Referer) hostname must be the website's domain, a subdomain of it, or one of its extraDomains — otherwise 403. Page script cannot choose these headers, so a tracker embedded on a foreign site — the id is public in every tracked page — reports that site and is refused, killing cross-site pollution from real browsers, deliberate or copy-pasted. The literal null of sandboxed frames is refused too; a request with neither header is dropped silently (every browser stamps Origin on a POST, so only hand-made clients send none). Refused hosts are remembered for the install guide's "Allow this host" only when they look like a browser's: a valid hostname, from a request carrying Sec-Fetch-Mode.
  • Declared automation. Crawlers, CLIs, HTTP libraries, fetchers and self-announcing headless engines (ua-parser-js's lists) are dropped silently — a 200 counting everything as dropped.
  • Per-source rate. A fixed-window event budget per (website, IP) — in-memory, per-instance — caps how fast any single source can write. Sized an order of magnitude above a heavy visitor so carrier-grade NAT never trips it; refusals are silent 200s for the same reason. The IP is the one the DMS resolves (getClientIp, @antelopejs/interface-dms/client-ip): X-Forwarded-For is only read behind the reverse proxies the DMS trusts (auth.trustedProxies, default 0), at the entry the outermost of them appended — anything left of it is the client's word, and would mint a visitor, dodge this budget or spend someone else's.

What remains — a scripted client forging a browser UA and a matching Origin — is accepted as unsolvable for an endpoint that must take anonymous traffic; no public analytics product authenticates its ingestion. The answer to that residue is detection and purge (per-site data purge ships; see Website lifecycle contract), not more gating.

The body is read under a cap (MAX_COLLECT_BODY_BYTES, 413 past it, by @RawBody) and must be JSON (400 otherwise). Only the envelope — website id, 1 to 25 events — can fail the request; each event is then validated on its own and a failing one is dropped (counted in dropped, logged once a minute per site), never the rest of its batch: a URL that is neither http(s) nor a path, a name missing or over its cap (50, the experiment key's 64 on exposure), data off its kind's shape (below) or over 4 KiB. Context text — title, referrer, screen, language — is cut to its cap instead:

{
  "website": "<website id>",
  "events": [
    {
      "kind": "pageview | custom | exposure | click | scroll",
      "url": "https://example.com/pricing?utm_source=newsletter",
      "referrer": "https://example.org/",        // optional
      "title": "Pricing",                        // optional
      "screen": "1920x1080",                     // optional
      "language": "fr-FR",                       // optional
      "name": "signup_submitted",                // required for custom (event name) and exposure (experiment key)
      "data": { "plan": "pro" },                 // kind-specific, ≤ 4 KiB serialized
      "at": 1754500000000                        // optional client ms, ignored past accepted skew
    }
  ]
}

Kind-specific data payloads:

KindPayload
click{x, y, dx, dy, selector, ox, oy, nth} — selector/ox/oy/nth are the element anchor heatmaps project from: nearest short CSS path (≤ 120), offset inside that element's box 0-1, and its index among the selector's matches. x/y are viewport-normalized (where on the screen), dx/dy document-normalized — both are fallbacks, see Heatmap anchoring below. Every fraction must lie in 0-1
pageview{sample} (optional) — the heatmap sampling rate 0-1 the tracker applied to the view; Pages & heatmaps reports "recorded on N % of page loads" as these declared rates averaged (counting scroll events against pageviews undercounts views left before their depth is sent). Any other payload is dropped, the pageview kept
scroll{depth} — max % reached on the page, an integer 0-100; every sampled view sends one, 0 when it never scrolled
exposure{experiment, variation} — the arm the page actually applied; name carries the experiment key. The arm is declarative: nothing re-derives it, the page is believed. The key is not — an exposure for anything but a running split is dropped at ingestion (see Assignment contract)

HTTP surface

RouteAuthPurpose
POST /api/marketing/collectpublicevent ingestion; answers {accepted, dropped}
GET /api/marketing/tracker.jspublicfirst-party tracker script; while collection is paused it only publishes a no-op window.dmsMarketing (cached a minute) so page code keeps working
GET /api/marketing/experiments.js?website=publicper-visitor assignment script for the site's funnels whose A/B split is running (see Assignment contract)
GET /api/marketing/snapshot.js?website=&path=&width=publicsnapshot negotiation: the capture script when the backend wants a snapshot of that path at that viewport width, an empty script otherwise (see Snapshot contract)
POST /api/marketing/snapshotpublicsnapshot upload — one serialized page rendering, capped and rate-limited, accepted only with the token its negotiation handed out
GET/POST/PUT/DELETE /api/marketing/websitestenant member / ownerwebsite CRUD; the list comes back most-recently-active first, so a dashboard landing on the first entry lands on one with data. snapshotsEnabled / snapshotMaskText are the page-snapshot opt-in and masking options; changing either discards the site's snapshots. DELETE /:id purges the site's tracking data with it (see Website lifecycle contract)
GET /api/marketing/websites/summarytenant memberthe Websites page cards: state, sessions / pages / funnels over 30 days, accepted hosts, snapshot and sampling state, recently refused hosts
GET /api/marketing/websites/:id/connectiontenant memberthe install guide's check: the site's state, its most recent visit (URL, browser, device) and its recently refused hosts
POST /api/marketing/websites/:id/allow-hosttenant owneradds a refused host to the site's extraDomains and forgets its refusals
POST /api/marketing/dashboard-trackingtenant memberwith dashboardTracking on, the website the dashboard's own tag reports to, { websiteId }: created for the caller's tenant on first call (id derived from the tenant, snapshots off), the request's Origin (else Referer) host added to its extraDomains when it is new. 404 while the option is off; 400 without a usable host
GET/PUT /api/marketing/contexttenant memberthe context bar: the tenant's websites with their live state and the caller's selection; PUT {website} stores the selection (see Context and period)
GET /api/marketing/blocks/kpi?metric=tenant memberone KpiCard: pageviews, sessions, new-visitors or custom-events, with its change and sparkline
GET /api/marketing/blocks/traffic?metric=tenant memberthe traffic ChartCard, the comparison window as a second series
GET /api/marketing/blocks/qualitytenant memberbounce rate, average session duration and pages per session, with their change
GET /api/marketing/blocks/devicestenant memberthe devices TopListCard
GET /api/marketing/blocks/tops?group=tenant memberone tabbed top-list card: content, acquisition, audience or events (goal events flagged)
GET /api/marketing/blocks/top?dimension=&limit=tenant memberone TopListCard over a rollup dimension (referrers, UTM contents/terms…), limit 5 by default, at most 50
GET /api/marketing/blocks/channelstenant membersessions per channel with share and change
GET /api/marketing/blocks/campaigns/rows?search=&filter_channel=is:<channel>tenant memberthe UTM campaign table (TableView.fromSource), { results, total }: top 100 (source, medium, campaign) triples with share, daily sessions, derived channel and the missing-medium flag
GET /api/marketing/blocks/campaigns/notice?kind=tagged|missingtenant membera notice by the campaign table for a stock Banner: the share of sessions tagged, or the sessions tagged without a medium; null when there is none
GET /api/marketing/blocks/collection-noticetenant memberthe "collection paused" notice over the Websites grid and in the install guide, for a stock Banner; null while the module collects
GET /api/marketing/stats/overview?website=&from=&to=tenant memberrollup-backed dashboard read
GET /api/marketing/stats/pages?website=&from=&to=&search=&limit=tenant memberinventory of observed paths, plus heatmapSampleRate and trackerEnabled so the caller can explain an empty heatmap
GET /api/marketing/stats/heatmap?website=&path=&from=&to=tenant memberclick heatmap (also served through the interface). website is optional and merges the tenant's sites when omitted — pass it
GET /api/marketing/stats/snapshot?website=&path=&layout=tenant memberthe page snapshot the heatmap is drawn over: the capture at that layout, else the nearest one (exact: false), else null. A document always wins over a row recording an oversize capture, which comes back as html: null with the bytes it reached
DELETE /api/marketing/stats/heatmap?website=&path=tenant ownerheatmap reset: the clicks, scroll depths and snapshots of one path, or of the whole site without path; answers {events, snapshots} deleted
GET /api/marketing/funnels/rows?filter_kind=is:funnel|abtenant memberthe funnels table (TableView.fromSource), { results, total }: per funnel, entered sessions, conversion and its change in points, or the A/B test's state
GET /api/marketing/funnels/winnertenant memberthe banner above the funnels table, for a stock Banner: days of the period raw events do not cover, else the running test ready to decide (significant, leader ahead, 7 days served, 50 conversions per arm), else a comparison left out; null otherwise
GET /api/marketing/funnels/templates?website=tenant memberfirst-run templates from the period's raw journeys: top custom events, each led by the page viewed most often before it, with the sessions that would enter
GET /api/marketing/funnels/:id/delete-confirmationtenant memberthe list's delete dialog worded for the row (a running test says it stops)
GET /api/marketing/funnels/suggestionstenant memberthe site's top pages and custom events of the last 30 days, for the builder's autocomplete
POST /api/marketing/funnels/previewtenant memberscores unsaved steps and window over the last 30 days, for the builder
GET /api/marketing/funnels/:id/resultstenant memberread-time funnel computation (each step with the events it matches, 0 for a step never sent), plus previous, the same computation over the comparison window (plain funnels and drafts, and only when raw events cover it — coverage says what they cover) — and, when the funnel carries an A/B split, the per-arm read through the same steps over the split's runs: exposed sessions, conversion, z-test verdicts with the lift interval (Bonferroni past two arms), SRM check, decision (days served, ready), and verdict — the one reading the report, its stop dialog and the list share: kind (winner, loser, flat, early, withheld) and the leader arm it names
/api/marketing/tables/funnels/*platform ownerfunnels DataController (TableView CRUD, tenant-instance-scoped via @TenantScopedModel; enforces the split's draft → running ⇄ stopped lifecycle, stamps the runs it produces, and freezes key/variations outside draft)
/api/marketing/tables/websites/*platform ownerread-only websites DataController (relation picker; global table, column-scoped)
GET/PUT /api/marketing/settingsplatform ownersettings form endpoints; PUT merges a partial body (a key left out keeps its value, null or the default itself clears it back to the config default), refuses (400, issue under the field) a snapshot retention longer than the raw-event retention or a statistics retention shorter than it, and answers 409 to the first save of a retention cut that deletes data (saving it again confirms)
GET/POST /api/marketing/settings/collectionplatform ownerthe master switch, the websites and their 30-day sessions; POST {enabled} flips it
GET /api/marketing/settings/collection/bannerplatform ownerthe Collection section for a stock Banner: state, coverage and the pause (confirmed) or resume action
GET /api/marketing/settings/glance?item=platform ownerone Meter of At a glance: raw, statistics or snapshots retention on a shared scale

The five public routes are declared to the DMS as the module's publicEndpoints (src/routes/public-endpoints.ts): the snippet the install guide shows points at the dashboard's origin, whose frontend server lets a tracked site's scripts and beacons through, without a session, only for declared routes.

Routes marked tenant member under /blocks, /stats, /funnels/rows, /funnels/winner, /funnels/templates and /funnels/:id/results read their window from the period scope (see Context and period); without from/to they read the last 7 days.

Client sites need no CORS grant: the tracker posts text/plain — a CORS-safelisted type, no preflight — and the backend parses the JSON body regardless of content type, gating on the browser-stamped Origin instead (see the ingestion guards above). The assignments read keeps the same property from the other direction: it is served as a script the page loads with <script src>, not JSON a page would need CORS to read. The @antelopejs/api CORS configuration only matters for the authenticated console origins.

Context and period

The analytics pages share one context: a website and a period. Both live in the context container (DmsMarketingContext, frontend-vue/app/components/Context.vue) that wraps the blocks of each page.

  • Period. The bar holds the DMS PeriodSelector (id marketing) — presets Today / 7D / 30D / 90D or a custom range, default 30 days; comparison with the previous period (default), the previous year, or none. The DMS keeps it in the page URL (?marketing.period=…&marketing.compare=…, marketing.from/marketing.to for a custom range) and the user's last choice, and publishes it under the period scope marketing, which every stock block of the pages binds to, source tables and banners included: the blocks append from/to (and compareFrom/compareTo, preset, comparison) to their fetch URL. The routes map these bounds on the UTC days carrying the same dates — the days rollup rows are keyed on — and cap a window at MAX_QUERY_PERIOD_DAYS (90), cut at its start so the recent days stay in (services/period.ts). Without from/to they read the last 7 days.
  • Days are UTC days. East of UTC, between local midnight and the UTC one, a window ending on the local today would name a UTC day nothing was counted in yet: it is moved back to end on the current UTC day. The bar says when the reader's days start ("UTC" hint). The day in progress is partial: the curve draws its last stretch dashed, the KPI sparklines leave it out, and every change compares it with the same elapsed share of the matching day of the comparison (RollupRead.compareRows). A comparison window with no rollup row is no comparison (no "vs 0"). A one-day window draws its curve hour by hour from the raw events (services/hourly-traffic.ts).
  • Clock. While the period reaches today, the bar asks the blocks to read again every minute (refreshLivePageBlocks()), through the live gate of the pages it sits on (live: CONTEXT_PAGE_LIVE, at most one read a minute per block); the DMS selector moves a day preset on at midnight. The bar's Refresh reads every block again at once (refreshPageBlocks()).
  • Website. The page URL names it (?website=), so each tab keeps its own and a copied link opens on the same site; a stock block reads it through a {{query.website}} token (websiteUrl(), src/pages/blocks.ts) and reads again when the bar switches it. A pick in the bar is also stored server-side per member, in marketing_preferences (PUT /api/marketing/context), as the website of the pages opened without one: the blocks fetch from the API origin, which need not share cookies with the console. A block route resolves its website as: the website query parameter, else the caller's stored selection while it still belongs to the tenant, else the most recently active site; 404 while the tenant has none (services/context.ts). Links between the module's pages carry the website and the selector's keys: the module's own components through useContextLink() (the website, then the DMS withPeriodQuery), a declared row action through {{query.X}} tokens the DMS fills from the page URL (CONTEXT_QUERY, src/pages/blocks.ts).
  • First run. While the tenant has no website, the container renders a first-run hero instead of its children, so no block fetches for a tenant without a site.

The context bar also shows each website's state from the same read: live once a pageview arrived, waiting before, paused when the site's own tracking switch is off. imported when the latest session activity is no newer than the latest instant the site's completed imports filled (marketing_imports.importedThrough): the data came from ImportAnalytics, and nothing proves the tag collects yet. The first collected hit after it turns the site live; a live session that ended before an import's latest event does not.

Snapshot contract

Two public routes, guarded like collect (browser origin, declared automation, per-source rate) and no further:

  • GET snapshot.js?website=&path=&width= answers the capture script (static/snapshot.js, the site's maskText, the byte cap and a one-time token prepended as window.__dmsMarketingSnapshotConfig) when a capture of (website, path, layout-of-width) is wanted, and an empty script in every other case — a page has nothing to do with a reason. Wanted means: the site opted in, no capture younger than SNAPSHOT_REFRESH_AGE_MS (a day) exists, and none was handed to another visitor within SNAPSHOT_PROMISE_TTL_MS (a minute). Both facts live in bounded per-instance caches (services/snapshots.ts) backed by one key lookup on a miss, so a busy page costs the database nothing between captures. Served no-store: the answer is a decision, not a file.
  • POST snapshot takes the serialized document as text/plain JSON (no preflight, like collect). The body is read under a hard cap (MAX_SNAPSHOT_BYTES, 1 MiB) by @RawBody, which refuses a declared length past it before a byte is read and cuts an undeclared one the moment it crosses (413). Then zod, the origin gate (here a request with neither Origin nor Referer is refused: the capture's fetch always sends one), the bot filter, a per-(website, IP) budget of MAX_SNAPSHOTS_PER_WINDOW_PER_SOURCE (3) a minute, and the negotiation itself: the upload must return the token handed for that very (website, path, layout) to that very visitor (same anonymous hash), whose open session must sit on that path — its last pageview, which the tracker flushes before negotiating. The token is spent on use. The page URL's host must be one the site declares (the stored origin is what the console's "Open" links to), and a cap of MAX_SNAPSHOTS_PER_WEBSITE (1000) captures per site applies. The document is not taken at its word: scripts, frames, plugins, event handlers, script URLs and form values are stripped again server-side and a Content-Security-Policy meta (script-src 'none'; form-action 'none'; …) is put in front, on top of the console's script-less sandbox. The row is upserted by the hash of (website, layout, path) — one per key, replaced in place — with the document gzipped. The body is one of two shapes: the capture, or — when serializing the page overshot the cap in the visitor's browser — oversizeBytes, the size it reached. The second writes the same row with an empty document and a zero box: a weight, not a rendering. It carries no document box on purpose, since a page heavy enough to overshoot the cap routinely exceeds MAX_SNAPSHOT_DOCUMENT_PX as well, and asking for one would refuse the very reports this collects.

The tracker asks only on visits already sampled for clicks (a backdrop is for pages with clicks), after load, a grace period and in idle time; the capture script waits for the DOM to stop mutating before serializing. The serialization rules — what is masked unconditionally, the integrator markers, the size cap — are in Pages & heatmaps; the rendering side is the sandbox described under Heatmap anchoring.

Accepted residues:

  • A promise is per instance. Two instances may each hand a capture to one visitor within the same minute; the second upload replaces the first. One redundant upload per instance per key per day at worst. An upload balanced onto another instance than its negotiation finds no token and is refused; the next sampled visit captures instead.
  • A determined forger can still replace a backdrop. A script that sends a pageview, negotiates and uploads from one address with browser headers goes through, like a forged beacon does. What it gets is bounded: a page of the site's own hosts, once a day per key, inert and without form values — and "Reset" in the console removes it.
  • A page too heavy to capture says so, once. Without the report, an oversize page was silent: no row, so wantsSnapshot elected another visitor to serialize it a minute later, forever, and the console could not tell it apart from a page never visited. The row holds the key for a refresh age like any capture, so the retry is daily and the surface can name the weight. What it costs: one of the site's 1000 slots, and a backdrop that an oversize report replaces when the page grows past the cap between two captures — the next capture under the cap takes it back.
  • A forged capture is a forged page. A scripted client with a browser UA and a matching Origin can upload any document for a path of a site that opted in, within the caps — or claim a weight it never had, bounded by MAX_REPORTED_SNAPSHOT_BYTES, and take that path's backdrop away until a real capture replaces it. It renders inert — the console frame runs no script, submits no form, navigates nowhere — and the answer is the same as for forged events: detection and per-site purge (the heatmap reset deletes snapshots too).
  • Retention is bounded by the raw events. Whatever snapshotRetention says, the prune uses the shorter of it and rawEventsRetention: a backdrop that outlives every click it could back is content kept for nothing.

Assignment contract

GET /api/marketing/experiments.js?website=<id> answers window.__dmsMarketingAssignments={"<experiment key>":"<variation key>"} for the site's funnels whose split is running — the tracker loads it lazily on the first variation() call. Assignment is a pure function of data already in hand: sha256(visitorId:experimentKey) buckets into the normalized weights (services/experiments/assign.ts), with the visitor id derived from the request's IP and user agent exactly as collect derives it. Nothing is stored per visitor; the recorded exposure events — not a re-derived assignment — are what results are computed from, so the analysis survives definition edits and salt rotation.

The endpoint is public and hardened like the other public surfaces, no further: definitions are cached per website id in a BoundedCache (30 s, negatives included — an unknown id costs at most one database read per TTL and answers an empty map, never an error a public page would surface); the parameter goes through queryString() and a length cap before touching the cache; the global and per-site kill switches short-circuit to the empty map; the body is Cache-Control: private, no-store — per-visitor, so a shared cache would hand the first visitor's buckets to everyone after it. No origin gate, no bot filter, no rate limit: those guards protect writes and data quality at ingestion, and the read has neither — a bot served an assignment map writes nothing, its exposures die at collect's own guards.

Accepted residues, in the spirit of the collect ones above:

  • The bucket follows the request, not the person. A visitor changing network or browser is a new visitor and may cross arms; two visitors behind one NAT sharing a user agent land in the same one. This is what a cookieless identity buys and costs. Called from a site's SSR renderer, every visitor shares the renderer's identity — the endpoint is browser-side by contract.
  • A flood of distinct ids costs a read each. The negative cache caps repetition per id, not invention; past MAX_CACHE_ENTRIES within the TTL each fresh id is a database read. Collect accepts exactly this (its limiter runs after website resolution); the answer is deployment-level rate limiting, not a guard invented for one route.
  • The arm an exposure claims is declarative. Its key is not: collect drops any exposure whose experiment is not currently running, off the same cached definitions the assignment script serves — one read per site per TTL, only on batches that carry an exposure. Which arm the page applied is still believed, since nothing re-derives it (that is what survives salt rotation and definition edits). A forged exposure on a running key is a forged custom event by another name; the answer to both is detection and per-site purge.
  • The salt rotates monthly, assignments with it. On the 1st every visitor re-draws. Sessions are the analysis unit and a session exposed to two arms is excluded, so the statistics stay valid — but a split spanning months mixes populations (documented in Funnels & A/B tests).
  • Start/stop propagates within the cache TTL per instance. The writing instance invalidates at once; others serve the previous set for up to 30 s, exactly as deleted-site beacons do. What that window lets through never reaches the numbers: results are read over the split's own runs (experiment.runs on the row — a stop closes one, a resume appends the next), so an exposure recorded outside them — a stale instance, a page loaded before the stop, a hand-rolled dmsMarketing.exposure() call — is dropped at read time as well. A resumed split keeps its earlier sessions because the arms are frozen and assignment is a pure function of the visitor hash: the same visitor draws the same arm across runs, salt rotation aside.
  • Running experiment keys are public. Anyone holding a website id — it is printed in every tracked page — can list them. Inherent to serving assignments to anonymous visitors; names and non-running splits stay behind the authenticated surface.

Heatmap anchoring

A click belongs to an element, not to a rectangle. A document fraction is not a property of the page. It divides by the document box, and on a page shorter than the visitor's window that box IS the window: the same button then arrives with a different dy from every window size, which both misplaces it under the overlay and smears one point of interest across as many cells as there were window heights. So each click also stores where it landed inside the element it hit (selector + ox/oy + nth), and that is what the overlay projects from and what the rollup buckets on.

Resolving a selector needs the rendered page, and the rendered page is the snapshot: the console runs querySelectorAll against the snapshot document itself, no round trip. Every way that can fail (no snapshot yet, element gone, selector now ambiguous) falls back to dx/dy, which is also all that clicks captured before anchoring ever had. Blocked elements keep their id and class for exactly this reason: a click on a masked widget still resolves to the widget's box.

The rollup caps anchored cells at MAX_ANCHORED_HEATMAP_CELLS (2000), shedding the least-clicked into the document grid: selectors are only as stable as the page's class names, and a build that hashes them would otherwise mint a fresh set per deploy until the response grew without bound. The document grid needs no cap — the 50×50 grid bounds it.

The snapshot frame. The console renders the snapshot's HTML in an <iframe srcdoc sandbox="allow-same-origin">: same-origin, so the document's scrollWidth/scrollHeight and every anchor box are read directly (frontend-vue/app/composables/useSnapshotFrame.ts) — no message protocol, no timeout, no silence to diagnose — and nothing else allowed, so a document that arrived through a public endpoint runs no script, submits no form and navigates nowhere; pointer-events: none on top keeps the admin's clicks out. The size is still taken from at most ONE reading, with the frame pinned at the captured viewport height until then: sizing the frame from a reading taken through it runs away, every 100vh section growing one tick at a time. What a live frame could never fix, the document can: before rendering, the console rewrites the viewport-relative units of the inlined stylesheets and inline styles (vh, svh, lvh, dvh, vmin, vmax — declaration values only, a Tailwind arbitrary value puts 100vh in the selector too) to the pixels they had in the visitor's viewport (frontend-vue/app/utils/snapshotViewportUnits.ts), so the one reading is the visitor's page height and the resize changes nothing inside. width matters as much as height, since click coordinates are fractions of the document's scrollWidth, not of the frame. The frame's color-scheme follows the visitor's mode at capture, so the page's media queries resolve the way they did for them.

Why a snapshot, not the live page. Framing the live site works only on sites that allow it, and production defaults (X-Frame-Options, CSP frame-ancestors) refuse it without any signal the console can read; a snapshot is what the visitor actually saw, on any site, whatever rendered it. The layout question survives it — one capture per (website, path, layout) with the visitor's viewport bucketed into the console's three widths (types/snapshots.ts), the nearest one reflowed when the asked layout has none. "Open in a new tab" targets the origin recorded with the capture; nothing is inferred from domain, which is apex-only and scheme-less.

Overlay switching. The click and scroll-depth overlays switch above one persistent iframe (a segmented control in PagePreview.vue), never a declarative Tab block — it unmounts hidden slots by default, which would tear down the frame and re-measure it on every switch. Later overlays (per-selector) join the same switch. The "Most clicked" list and the count pins read the same anchored cells the overlay draws, resolved against the same frame, so they need no read of their own.

Scroll depth. A scroll event stores the max depth reached per sampled view — an integer percent of the visitor's scrollable range, flushed on leave and on SPA navigation. A sampled view that never scrolls reports 0; a page that fits its viewport reports 100. The read (GET /api/marketing/stats/scroll, services/heatmap.ts) reports a cumulative reached-at-depth distribution over measured views, not all traffic. Older events recorded only views that scrolled, so historical windows can have a different denominator. The overlay (frontend-vue/app/components/ScrollDepthOverlay.vue) anchors depth 0 at the bottom of the probe viewport: the visitors' real viewports are unknowable here, so the gradient claims bands, not pixels — and it draws in the pane's scaled space, where depth only varies vertically and labels keep their font size at every zoom. Resetting a heatmap deletes clicks, scroll depths and the matching page snapshots together (DELETE /api/marketing/stats/heatmap).

Inter-module interface

dms-marketing implements @antelopejs/interface-dms-marketing (declared in antelopeJs.implements), which exposes the click heatmap to other backend modules and lets them ship content as code: EnsureWebsite and EnsureFunnel (services/declared-website.ts, services/declared-funnel.ts) require explicit tenant ids, as do EnsurePageSnapshot and ImportAnalytics: missing, blank or padded ids reject before model access. There is no tenant fallback in these four APIs. Tenant selection is not authorization or provisioning; applications own durable created consumers and committed-tenant inventory/backfill workflows, as described in the consumer reference. The declarations write through the same field and lifecycle rules as the dashboard and store a declaredHash on the row, the content they last wrote. Refresh and hash reset use model-level conditional writes against the observed content and hash (including funnel experiment runs). MongoDB retains these predicates in the native update; another adapter must provide the same write-time condition, which generic filtered writes do not promise. A mismatch returns kept without retry or post-refresh side effects. A successful divergence reset permanently leaves content to its owner even if they restore the original values. This is not a revision/ABA guarantee; EnsurePageSnapshot (services/declared-snapshot.ts) stores a snapshot under the key a capture of the same page and layout takes, sanitized by the same function, with a declaredHash over its origin, sizes, color scheme and sanitized document. A capture replaces the whole row without the hash, so it is never overwritten; a refresh writes only while the row keeps the capture time and hash it was read with.

ImportAnalytics (services/analytics-import.ts) replays history through ingestPreparedBatch, the very function behind POST /collect, with a trusted clock. A permanent marketing_imports row deduplicates the import id and stores its chronology boundary; a reserved per-website row serializes imports across instances. Earlier windows reject before replay, and a failed replay retains the website claim pending reconciliation. Imported batches bypass the session cache to see other instances' writes. All four wait for the module's DATABASE_INITIALIZED handler (services/readiness.ts) before touching a table. It has no in-tree consumer since dms-api's tab was removed; it is kept because every /modules/ page is platform-owner-only by DMS design, so the interface is the only route by which a project module can build a tenant-facing heatmap.

The interface is published as a separate workspace package at packages/interface-dms-marketing, beside this module's own packages/dms-marketing. Local consumers get the workspace link, because link-workspace-packages makes pnpm resolve the module's >=<interface version> <0.<minor+1>.0 dependency to the sibling package. The module implements the interface, so that range stops below the next minor instead of <1.0.0: a breaking interface minor never reaches a module that does not implement it, and release.yml refuses any other shape. Each package has its own manually dispatched release workflow, and the module's refuses to run until the interface version it links is resolvable on npm — so the interface is always released first.

Why the heatmap moved here from dms-api. It used to live in dms-api's route detail. That was wrong in principle, not in execution: the identifier dms-api can produce is a registered backend route (getRegisteredRoutes()), matched with a strict eq against visitor pathnames, so a location carrying :id matches nothing, ever — and its default preview origin was the DMS API itself, which framed a JSON endpoint. dms-api no longer consumes the marketing interface.

Frontend layout

The pages are declared backend-side (src/pages/) as DMS page controllers of the module marketing (src/pages/module.ts), each one a tree of interface-dms blocks. The sidebar headings are three label categories with urlSlug: "/", so they group the pages without entering their URLs:

HeadingPageURL
AnalyticsOverview/modules/marketing/overview
AnalyticsAcquisition/modules/marketing/acquisition
AnalyticsPages & heatmaps/modules/marketing/pages (?path=)
ConversionFunnels & A/B tests/modules/marketing/funnels
Conversion (hidden)Funnel report/modules/marketing/funnel?id=
Conversion (hidden)Funnel builder/modules/marketing/funnel-builder (?id= to edit, ?copy= to duplicate, ?split=1, ?steps= template; links to it carry the page's context, which its own links out keep)
SetupWebsites/modules/marketing/websites
Setup (hidden)Install guide/modules/marketing/install (?website=)
SetupSettings/modules/marketing/settings

Stock blocks render whatever a stock block can — KpiCard, ChartCard, TopListCard, Grid, KeyValueList, Section, FieldRow, Meter, Form, StatGroup, Banner, TableView.fromSource — the analytics ones bound to the marketing period scope and reading the /blocks/* routes for the page's website. Hidden pages are reached from the others, never from the sidebar. The rest are module blocks, CustomComponent("DmsMarketing<Name>") built with MarketingBlock() (src/pages/blocks.ts): Context (the context bar and first-run gate), TopListTabs, ChannelsCard, PagesExplorer (inventory and heatmap preview), FunnelTemplates (the funnels table's first-run state), FunnelReport (with FunnelFigure and ExperimentReport), FunnelBuilder, WebsitesGrid and InstallGuide. The Collection section is a stock Banner the route words (/settings/collection/banner), with the pause behind a confirmation and the resume as its actions. The Settings forms share their field definitions with the endpoint's validation schema (src/pages/settings/form.ts).

The DMS sidebar carries two counts (navBadge): the running A/B tests on Funnels, and the websites still waiting for their first pageview on Websites. The module's catalog tile shows the package version, the Analytics catalog category, and a status and readout line that turn to attention / Collection is paused while collection is off (src/services/catalog.ts).

The module blocks ship as a DMS frontend module: frontend-vue/, a Vue 3 project whose root dms.frontend.ts declares componentPrefix: "DmsMarketing" and registers every component of app/components/ under its bare file name (Context, FunnelReport…) — @antelopejs/dms-frontend 0.5 adds the prefix, so the backend addresses them as DmsMarketingContext, DmsMarketingFunnelReport… It also registers the funnel-steps DataType plugin. Composables and utils are imported by path, not auto-imported, which keeps them testable with plain vitest. What a page renders is read with the DMS's useApiData (composables/useMarketingReads.ts): a server render reads it and hands it to the browser with the page. Context reads the websites before its blocks set up, so a URL naming no website is redirected to the saved one and its blocks render on the server with their period (once the browser's time zone cookie is known); the page preview's frames and the funnel builder's working copy read in the browser (useMarketingApi). i18n keys live under page.marketing.* (en-GB + fr-FR) in frontend-vue/i18n/locales/, which the loader merges into the console's catalog. AddFrontendModule (src/index.ts) publishes the directory to the backend's frontend manifest, and ajs dms materializes it into the generated Inertia workspace it builds and serves.

Phasing

  1. Audience measurement — shipped: dashboards on rollups, website CRUD.
  2. Funnels & retention — funnels shipped (read-time compute); session cohorts and daily funnel snapshots pending.
  3. A/B — shipped: server-side deterministic assignment (experiments.js), exposure capture, and the step-split results read (z-test + SRM). The split is a facet of the funnel itself (marketing_funnels.experiment), scored against the funnel's own steps — one definition, one page, no goal to pick. A first cut was withdrawn at cf93e20 — storage and a GrowthBook definitions endpoint shipped without CRUD, betting on a client SDK hashing a visitorId the client never has; the revival assigns server-side instead (see Assignment contract).
  4. Heatmaps — shipped: click and scroll-depth overlays over a page snapshot captured by the tracker. A per-selector view is the remaining overlay.
  5. Session replay (optional) — rrweb behind explicit consent, masked inputs, short retention, sampled. Not started.

Known scaffold limits

  • Session resolution is serialized per website+visitor on each instance (services/sessionize.ts), and instances resolve against the database row another instance wrote; only simultaneous first hits of one visitor on two instances can still open two sessions, and the next idle timeout merges the visitor back onto one.
  • Rollup scalar counters are native increments and correct across instances; the top-N maps are last-write-wins between instances, an approximation the maps already accept by evicting on a cap (services/rollup-write.ts).
  • The collect path resolves websites through a TTL cache (WEBSITE_CACHE_TTL_MS): after a site is deleted, beacons cached on OTHER instances can land for up to the TTL. The deleting instance invalidates its own entry immediately.
  • Funnel computation is read-time over a bounded raw window (MAX_FUNNEL_EVENTS = 200 000), and flags truncated when the cap is hit — verdicts are then withheld, since the truncation keeps the oldest events and silently drops the recent ones. A funnel carrying a started split is read over the split's runs instead of the selected period, so its results are the same whatever period the context bar shows and a stopped one is final; the read runs one conversion window past the last stop (late goals still count), while exposures landing there do not. One fetch spans first start to last stop, pauses included — a range scan per run would cost more than the rows a pause holds. A long-running split is therefore read over its whole life — closer to the cap than a 30-day period suggests, and still bounded by the raw-event retention.
  • Split analysis is per session, uses Bonferroni correction for multiple challengers against the control, and is bounded by the raw-event retention on both sides of the ratio; the per-arm z-test and the SRM check answer null rather than a number whenever their approximation's validity floor is not met. Resuming a stopped split is allowed and cumulates its runs, which puts optional stopping within reach of anyone who reads a p-value before deciding — the Resume confirmation and the usage guide warn against it, nothing enforces it.
  • Module pages are owner-only by DMS design; customer-facing dashboards must land in project pages (phase 1 decision).
  • The refused-host memory behind the install guide is per instance and in memory (an hour, 5 hosts per site): behind a load balancer, the instance answering the poll may not be the one that refused the beacon.
  • Defects found end-to-end and how they were closed are in KNOWN-ISSUES.md.