Architecture
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_eventsis kept at all. cms-audit died storing raw rows only; the daily-rollup idea comes from dms-api'sRouteStatistics, 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. PastTOP_MAPS_RETENTION_DAYS(455: a year plus the 90 days ofMAX_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, inTOP_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
domainor 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
geoipDatabasePathconfigured, the collect request's IP resolves to an ISO country code against a local MaxMind.mmdbfile (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;topCampaignsadditionally 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 thetopChannelscounters, 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 thewebsiteId → tenantIdrouting 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(fallbackReferer) hostname must be the website'sdomain, a subdomain of it, or one of itsextraDomains— 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 literalnullof sandboxed frames is refused too; a request with neither header is dropped silently (every browser stampsOriginon 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 carryingSec-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-Foris 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:
| Kind | Payload |
|---|---|
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
| Route | Auth | Purpose |
|---|---|---|
POST /api/marketing/collect | public | event ingestion; answers {accepted, dropped} |
GET /api/marketing/tracker.js | public | first-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= | public | per-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= | public | snapshot 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/snapshot | public | snapshot upload — one serialized page rendering, capped and rate-limited, accepted only with the token its negotiation handed out |
GET/POST/PUT/DELETE /api/marketing/websites | tenant member / owner | website 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/summary | tenant member | the Websites page cards: state, sessions / pages / funnels over 30 days, accepted hosts, snapshot and sampling state, recently refused hosts |
GET /api/marketing/websites/:id/connection | tenant member | the 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-host | tenant owner | adds a refused host to the site's extraDomains and forgets its refusals |
POST /api/marketing/dashboard-tracking | tenant member | with 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/context | tenant member | the 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 member | one KpiCard: pageviews, sessions, new-visitors or custom-events, with its change and sparkline |
GET /api/marketing/blocks/traffic?metric= | tenant member | the traffic ChartCard, the comparison window as a second series |
GET /api/marketing/blocks/quality | tenant member | bounce rate, average session duration and pages per session, with their change |
GET /api/marketing/blocks/devices | tenant member | the devices TopListCard |
GET /api/marketing/blocks/tops?group= | tenant member | one tabbed top-list card: content, acquisition, audience or events (goal events flagged) |
GET /api/marketing/blocks/top?dimension=&limit= | tenant member | one TopListCard over a rollup dimension (referrers, UTM contents/terms…), limit 5 by default, at most 50 |
GET /api/marketing/blocks/channels | tenant member | sessions per channel with share and change |
GET /api/marketing/blocks/campaigns/rows?search=&filter_channel=is:<channel> | tenant member | the 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|missing | tenant member | a 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-notice | tenant member | the "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 member | rollup-backed dashboard read |
GET /api/marketing/stats/pages?website=&from=&to=&search=&limit= | tenant member | inventory 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 member | click 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 member | the 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 owner | heatmap 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|ab | tenant member | the 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/winner | tenant member | the 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 member | first-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-confirmation | tenant member | the list's delete dialog worded for the row (a running test says it stops) |
GET /api/marketing/funnels/suggestions | tenant member | the site's top pages and custom events of the last 30 days, for the builder's autocomplete |
POST /api/marketing/funnels/preview | tenant member | scores unsaved steps and window over the last 30 days, for the builder |
GET /api/marketing/funnels/:id/results | tenant member | read-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 owner | funnels 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 owner | read-only websites DataController (relation picker; global table, column-scoped) |
GET/PUT /api/marketing/settings | platform owner | settings 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/collection | platform owner | the master switch, the websites and their 30-day sessions; POST {enabled} flips it |
GET /api/marketing/settings/collection/banner | platform owner | the Collection section for a stock Banner: state, coverage and the pause (confirmed) or resume action |
GET /api/marketing/settings/glance?item= | platform owner | one 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(idmarketing) — 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.tofor a custom range) and the user's last choice, and publishes it under the period scopemarketing, which every stock block of the pages binds to, source tables and banners included: the blocks appendfrom/to(andcompareFrom/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 atMAX_QUERY_PERIOD_DAYS(90), cut at its start so the recent days stay in (services/period.ts). Withoutfrom/tothey 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, inmarketing_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: thewebsitequery 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 throughuseContextLink()(the website, then the DMSwithPeriodQuery), 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'smaskText, the byte cap and a one-time token prepended aswindow.__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 thanSNAPSHOT_REFRESH_AGE_MS(a day) exists, and none was handed to another visitor withinSNAPSHOT_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. Servedno-store: the answer is a decision, not a file.POST snapshottakes the serialized document astext/plainJSON (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 neitherOriginnorRefereris refused: the capture'sfetchalways sends one), the bot filter, a per-(website, IP) budget ofMAX_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 ofMAX_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 aContent-Security-Policymeta (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 exceedsMAX_SNAPSHOT_DOCUMENT_PXas 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
wantsSnapshotelected 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
snapshotRetentionsays, the prune uses the shorter of it andrawEventsRetention: 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_ENTRIESwithin 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
exposurewhose 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.runson 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-rolleddmsMarketing.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:
| Heading | Page | URL |
|---|---|---|
| Analytics | Overview | /modules/marketing/overview |
| Analytics | Acquisition | /modules/marketing/acquisition |
| Analytics | Pages & heatmaps | /modules/marketing/pages (?path=) |
| Conversion | Funnels & 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) |
| Setup | Websites | /modules/marketing/websites |
| Setup (hidden) | Install guide | /modules/marketing/install (?website=) |
| Setup | Settings | /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
- Audience measurement — shipped: dashboards on rollups, website CRUD.
- Funnels & retention — funnels shipped (read-time compute); session cohorts and daily funnel snapshots pending.
- 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 atcf93e20— storage and a GrowthBook definitions endpoint shipped without CRUD, betting on a client SDK hashing avisitorIdthe client never has; the revival assigns server-side instead (see Assignment contract). - Heatmaps — shipped: click and scroll-depth overlays over a page snapshot captured by the tracker. A per-selector view is the remaining overlay.
- 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 flagstruncatedwhen 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.