@antelopejs/dms-marketing 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +4 -4
  2. package/dist/config.d.ts +10 -1
  3. package/dist/config.js +29 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/db/models/funnels.model.d.ts +1 -0
  6. package/dist/db/models/funnels.model.js +18 -0
  7. package/dist/db/models/funnels.model.js.map +1 -1
  8. package/dist/db/models/index.d.ts +1 -0
  9. package/dist/db/models/index.js +1 -0
  10. package/dist/db/models/index.js.map +1 -1
  11. package/dist/db/models/marketing_imports.model.d.ts +27 -0
  12. package/dist/db/models/marketing_imports.model.js +38 -0
  13. package/dist/db/models/marketing_imports.model.js.map +1 -0
  14. package/dist/db/models/page_snapshots.model.d.ts +1 -0
  15. package/dist/db/models/page_snapshots.model.js +14 -0
  16. package/dist/db/models/page_snapshots.model.js.map +1 -1
  17. package/dist/db/models/websites.model.d.ts +1 -0
  18. package/dist/db/models/websites.model.js +29 -0
  19. package/dist/db/models/websites.model.js.map +1 -1
  20. package/dist/db/tables/funnels.table.d.ts +1 -0
  21. package/dist/db/tables/funnels.table.js +4 -0
  22. package/dist/db/tables/funnels.table.js.map +1 -1
  23. package/dist/db/tables/index.d.ts +1 -0
  24. package/dist/db/tables/index.js +1 -0
  25. package/dist/db/tables/index.js.map +1 -1
  26. package/dist/db/tables/marketing_imports.table.d.ts +15 -0
  27. package/dist/db/tables/marketing_imports.table.js +63 -0
  28. package/dist/db/tables/marketing_imports.table.js.map +1 -0
  29. package/dist/db/tables/page_snapshots.table.d.ts +1 -0
  30. package/dist/db/tables/page_snapshots.table.js +4 -0
  31. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  32. package/dist/db/tables/websites.table.d.ts +1 -0
  33. package/dist/db/tables/websites.table.js +4 -0
  34. package/dist/db/tables/websites.table.js.map +1 -1
  35. package/dist/hooks/index.js +1 -0
  36. package/dist/hooks/index.js.map +1 -1
  37. package/dist/implementations/dms-marketing/index.d.ts +5 -1
  38. package/dist/implementations/dms-marketing/index.js +25 -0
  39. package/dist/implementations/dms-marketing/index.js.map +1 -1
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +4 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/routes/dashboard-tracking.d.ts +9 -0
  44. package/dist/routes/dashboard-tracking.js +57 -0
  45. package/dist/routes/dashboard-tracking.js.map +1 -0
  46. package/dist/routes/index.d.ts +1 -0
  47. package/dist/routes/index.js +1 -0
  48. package/dist/routes/index.js.map +1 -1
  49. package/dist/routes/stats.js.map +1 -1
  50. package/dist/routes/websites.js +12 -25
  51. package/dist/routes/websites.js.map +1 -1
  52. package/dist/services/analytics-import.d.ts +2 -0
  53. package/dist/services/analytics-import.js +294 -0
  54. package/dist/services/analytics-import.js.map +1 -0
  55. package/dist/services/collect.d.ts +20 -0
  56. package/dist/services/collect.js +43 -17
  57. package/dist/services/collect.js.map +1 -1
  58. package/dist/services/context.d.ts +8 -3
  59. package/dist/services/context.js +21 -7
  60. package/dist/services/context.js.map +1 -1
  61. package/dist/services/dashboard-tracking.d.ts +25 -0
  62. package/dist/services/dashboard-tracking.js +85 -0
  63. package/dist/services/dashboard-tracking.js.map +1 -0
  64. package/dist/services/dashboard.js +7 -3
  65. package/dist/services/dashboard.js.map +1 -1
  66. package/dist/services/declarations.d.ts +11 -0
  67. package/dist/services/declarations.js +64 -0
  68. package/dist/services/declarations.js.map +1 -0
  69. package/dist/services/declared-funnel.d.ts +2 -0
  70. package/dist/services/declared-funnel.js +230 -0
  71. package/dist/services/declared-funnel.js.map +1 -0
  72. package/dist/services/declared-snapshot.d.ts +2 -0
  73. package/dist/services/declared-snapshot.js +113 -0
  74. package/dist/services/declared-snapshot.js.map +1 -0
  75. package/dist/services/declared-website.d.ts +2 -0
  76. package/dist/services/declared-website.js +118 -0
  77. package/dist/services/declared-website.js.map +1 -0
  78. package/dist/services/geoip.js.map +1 -1
  79. package/dist/services/index.d.ts +1 -0
  80. package/dist/services/index.js +1 -0
  81. package/dist/services/index.js.map +1 -1
  82. package/dist/services/readiness.d.ts +2 -0
  83. package/dist/services/readiness.js +15 -0
  84. package/dist/services/readiness.js.map +1 -0
  85. package/dist/services/sessionize.d.ts +1 -0
  86. package/dist/services/sessionize.js +3 -0
  87. package/dist/services/sessionize.js.map +1 -1
  88. package/dist/services/snapshots.d.ts +3 -0
  89. package/dist/services/snapshots.js +6 -0
  90. package/dist/services/snapshots.js.map +1 -1
  91. package/dist/services/tenant-website.js +1 -0
  92. package/dist/services/tenant-website.js.map +1 -1
  93. package/dist/services/website-report.js +4 -3
  94. package/dist/services/website-report.js.map +1 -1
  95. package/dist/services/website-rules.d.ts +8 -0
  96. package/dist/services/website-rules.js +34 -0
  97. package/dist/services/website-rules.js.map +1 -0
  98. package/dist/types/constants.d.ts +3 -0
  99. package/dist/types/constants.js +5 -2
  100. package/dist/types/constants.js.map +1 -1
  101. package/dist/types/snapshots.d.ts +55 -0
  102. package/dist/types/snapshots.js +8 -1
  103. package/dist/types/snapshots.js.map +1 -1
  104. package/frontend-vue/app/components/Context.vue +5 -1
  105. package/frontend-vue/app/components/WebsitesGrid.vue +6 -2
  106. package/frontend-vue/app/composables/useMarketingApi.ts +12 -0
  107. package/frontend-vue/app/composables/useMarketingContext.ts +2 -1
  108. package/frontend-vue/app/constants.ts +6 -0
  109. package/frontend-vue/app/plugins/dashboard-tracking.client.ts +44 -0
  110. package/frontend-vue/app/utils/dashboard-tracker.ts +127 -0
  111. package/frontend-vue/app/utils/format.ts +6 -3
  112. package/frontend-vue/dms.frontend.ts +10 -0
  113. package/frontend-vue/i18n/locales/marketing-en-GB.json +2 -0
  114. package/frontend-vue/i18n/locales/marketing-fr-FR.json +2 -0
  115. package/frontend-vue/tests/dashboard-tracking.test.ts +219 -0
  116. package/frontend-vue/tests/format.test.ts +9 -0
  117. package/frontend-vue/tests/server-parity.test.ts +5 -0
  118. package/frontend-vue/tests/stubs/frontend-module.ts +7 -0
  119. package/package.json +2 -2
  120. package/skills/dms-marketing/SKILL.md +52 -2
  121. package/static/tracker.js +90 -5
  122. package/frontend-vue/pnpm-workspace.yaml +0 -2
@@ -386,6 +386,7 @@
386
386
  "title": "Couldn't load your websites"
387
387
  },
388
388
  "idle": "Last event {ago}",
389
+ "imported": "Imported history · last event {ago}",
389
390
  "label": "Website and period",
390
391
  "live": "Live · last event {ago}",
391
392
  "no_website": "No website yet",
@@ -399,6 +400,7 @@
399
400
  "range_cut": "Only the last {days} days of this range are shown.",
400
401
  "refresh": "Refresh",
401
402
  "state": {
403
+ "imported": "Imported history only",
402
404
  "live": "Live",
403
405
  "paused": "Tracking paused for this website",
404
406
  "waiting": "Waiting for the first event"
@@ -386,6 +386,7 @@
386
386
  "title": "Impossible de charger vos sites"
387
387
  },
388
388
  "idle": "Dernier événement {ago}",
389
+ "imported": "Historique importé · dernier événement {ago}",
389
390
  "label": "Site et période",
390
391
  "live": "En direct · dernier événement {ago}",
391
392
  "no_website": "Aucun site pour l'instant",
@@ -399,6 +400,7 @@
399
400
  "range_cut": "Seuls les {days} derniers jours de cette plage sont affichés.",
400
401
  "refresh": "Actualiser",
401
402
  "state": {
403
+ "imported": "Historique importé uniquement",
402
404
  "live": "En direct",
403
405
  "paused": "Suivi en pause pour ce site",
404
406
  "waiting": "En attente du premier événement"
@@ -0,0 +1,219 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { nextTick, ref } from 'vue'
3
+ import frontendModule from '../dms.frontend'
4
+ import { MODULE_CONFIG_KEY } from '../app/constants'
5
+ import {
6
+ applyDashboardTracker,
7
+ DASHBOARD_TRACKER_ELEMENT_ID,
8
+ dashboardTrackingOptions,
9
+ followDashboardSession,
10
+ } from '../app/utils/dashboard-tracker'
11
+
12
+ /** Just enough of a script element and a document for the tag helper. */
13
+ class FakeElement {
14
+ id = ''
15
+ async = false
16
+ src = ''
17
+ readonly attributes = new Map<string, string>()
18
+ setAttribute(name: string, value: string) {
19
+ this.attributes.set(name, value)
20
+ }
21
+ removeAttribute(name: string) {
22
+ this.attributes.delete(name)
23
+ }
24
+ }
25
+
26
+ function fakeDocument() {
27
+ const appended: FakeElement[] = []
28
+ const doc = {
29
+ appended,
30
+ getElementById: (id: string) =>
31
+ appended.find((element) => element.id === id) ?? null,
32
+ createElement: () => new FakeElement(),
33
+ head: {
34
+ appendChild: (element: FakeElement) => {
35
+ appended.push(element)
36
+ return element
37
+ },
38
+ },
39
+ }
40
+ return doc as typeof doc & Parameters<typeof applyDashboardTracker>[0]
41
+ }
42
+
43
+ const SRC = 'https://dms.example.com/api/marketing/tracker.js'
44
+ const EXCLUDED = ['/auth/**', '/settings/*']
45
+
46
+ function sdk(publicOptions: Record<string, unknown>) {
47
+ return {
48
+ options: { public: publicOptions },
49
+ registerComponent: vi.fn(),
50
+ registerPage: vi.fn(),
51
+ registerDynamicPage: vi.fn(),
52
+ registerLayout: vi.fn(),
53
+ registerErrorPage: vi.fn(),
54
+ registerPlugin: vi.fn(),
55
+ registerMiddleware: vi.fn(),
56
+ provide: vi.fn(),
57
+ use: vi.fn(),
58
+ }
59
+ }
60
+
61
+ describe('dashboard tracking options', () => {
62
+ it('is off without the backend option', () => {
63
+ expect(dashboardTrackingOptions({})).toBeNull()
64
+ expect(dashboardTrackingOptions({ [MODULE_CONFIG_KEY]: {} })).toBeNull()
65
+ })
66
+
67
+ it('reads the excluded paths, strings only', () => {
68
+ expect(
69
+ dashboardTrackingOptions({
70
+ [MODULE_CONFIG_KEY]: {
71
+ dashboardTracking: { excludePaths: ['/auth/**', 3, '/a'] },
72
+ },
73
+ }),
74
+ ).toEqual({ excludePaths: ['/auth/**', '/a'] })
75
+ expect(
76
+ dashboardTrackingOptions({
77
+ [MODULE_CONFIG_KEY]: { dashboardTracking: {} },
78
+ }),
79
+ ).toEqual({ excludePaths: [] })
80
+ })
81
+
82
+ it('registers its client plugin only when the backend enables it', async () => {
83
+ const off = sdk({})
84
+ await frontendModule.setup(off)
85
+ expect(off.registerPlugin).toHaveBeenCalledTimes(1)
86
+
87
+ const on = sdk({
88
+ [MODULE_CONFIG_KEY]: { dashboardTracking: { excludePaths: EXCLUDED } },
89
+ })
90
+ await frontendModule.setup(on)
91
+ expect(on.registerPlugin).toHaveBeenCalledTimes(2)
92
+ for (const [, options] of on.registerPlugin.mock.calls) {
93
+ expect(options).toEqual({ clientOnly: true })
94
+ }
95
+ })
96
+ })
97
+
98
+ describe('the dashboard tag', () => {
99
+ it('is not inserted before there is a website to report to', () => {
100
+ const doc = fakeDocument()
101
+ applyDashboardTracker(doc, {
102
+ src: SRC,
103
+ websiteId: null,
104
+ excludePaths: EXCLUDED,
105
+ })
106
+ expect(doc.appended).toHaveLength(0)
107
+ })
108
+
109
+ it('is inserted once, then paused and retargeted in place', () => {
110
+ const doc = fakeDocument()
111
+ applyDashboardTracker(doc, {
112
+ src: SRC,
113
+ websiteId: 'site-a',
114
+ excludePaths: EXCLUDED,
115
+ })
116
+ expect(doc.appended).toHaveLength(1)
117
+ const tag = doc.appended[0]!
118
+ expect(tag.id).toBe(DASHBOARD_TRACKER_ELEMENT_ID)
119
+ expect(tag.src).toBe(SRC)
120
+ expect(tag.async).toBe(true)
121
+ expect(tag.attributes.get('data-website-id')).toBe('site-a')
122
+ expect(tag.attributes.get('data-exclude-paths')).toBe(
123
+ '/auth/**,/settings/*',
124
+ )
125
+
126
+ applyDashboardTracker(doc, {
127
+ src: SRC,
128
+ websiteId: null,
129
+ excludePaths: EXCLUDED,
130
+ })
131
+ expect(tag.attributes.has('data-website-id')).toBe(false)
132
+
133
+ applyDashboardTracker(doc, {
134
+ src: SRC,
135
+ websiteId: 'site-b',
136
+ excludePaths: EXCLUDED,
137
+ })
138
+ expect(doc.appended).toHaveLength(1)
139
+ expect(tag.attributes.get('data-website-id')).toBe('site-b')
140
+ })
141
+ })
142
+
143
+ describe('following the signed-in workspace', () => {
144
+ function binding(answers: Record<string, () => Promise<string>>) {
145
+ const workspace = ref<string | null>(null)
146
+ const applied: (string | null)[] = []
147
+ const provision = vi.fn(() => answers[workspace.value!]!())
148
+ const stop = followDashboardSession({
149
+ workspace: () => workspace.value,
150
+ provision,
151
+ apply: (websiteId) => applied.push(websiteId),
152
+ })
153
+ return { workspace, applied, provision, stop }
154
+ }
155
+
156
+ async function settle() {
157
+ await new Promise((resolve) => setTimeout(resolve, 0))
158
+ await nextTick()
159
+ }
160
+
161
+ it('asks nothing while nobody is signed in', async () => {
162
+ const { applied, provision, stop } = binding({})
163
+ await settle()
164
+ expect(provision).not.toHaveBeenCalled()
165
+ expect(applied).toEqual([null])
166
+ stop()
167
+ })
168
+
169
+ it('pauses at once on a change, then reports to the new website', async () => {
170
+ const { workspace, applied, stop } = binding({
171
+ t1: async () => 'site-1',
172
+ t2: async () => 'site-2',
173
+ })
174
+ workspace.value = 't1'
175
+ await settle()
176
+ expect(applied).toEqual([null, null, 'site-1'])
177
+
178
+ workspace.value = 't2'
179
+ // Synchronous: paused before the navigation that follows the switch.
180
+ expect(applied.at(-1)).toBeNull()
181
+ await settle()
182
+ expect(applied.at(-1)).toBe('site-2')
183
+
184
+ workspace.value = null
185
+ expect(applied.at(-1)).toBeNull()
186
+ await settle()
187
+ expect(applied.at(-1)).toBeNull()
188
+ stop()
189
+ })
190
+
191
+ it('drops an answer that arrives after another change', async () => {
192
+ let release: (id: string) => void = () => {}
193
+ const { workspace, applied, stop } = binding({
194
+ slow: () =>
195
+ new Promise<string>((resolve) => {
196
+ release = resolve
197
+ }),
198
+ })
199
+ workspace.value = 'slow'
200
+ workspace.value = null
201
+ release('site-slow')
202
+ await settle()
203
+ expect(applied).not.toContain('site-slow')
204
+ expect(applied.at(-1)).toBeNull()
205
+ stop()
206
+ })
207
+
208
+ it('stays paused when the backend refuses', async () => {
209
+ const { workspace, applied, stop } = binding({
210
+ t1: async () => {
211
+ throw new Error('404')
212
+ },
213
+ })
214
+ workspace.value = 't1'
215
+ await settle()
216
+ expect(applied.at(-1)).toBeNull()
217
+ stop()
218
+ })
219
+ })
@@ -46,6 +46,15 @@ describe('formatting', () => {
46
46
  expect(initials('docs')).toBe('DO')
47
47
  })
48
48
 
49
+ it('builds initials from letters and digits only', () => {
50
+ expect(initials('Acme — public site')).toBe('AP')
51
+ expect(initials('shop.acme.test')).toBe('SA')
52
+ expect(initials('— Acme')).toBe('AC')
53
+ expect(initials('42 · store')).toBe('4S')
54
+ expect(initials('Été à Liège')).toBe('ÉÀ')
55
+ expect(initials('—')).toBe('?')
56
+ })
57
+
49
58
  it('falls back to the raw code on a malformed region', () => {
50
59
  expect(displayName('FR', 'region', 'en')).toBe('France')
51
60
  expect(displayName('not a code!', 'region', 'en')).toBe('not a code!')
@@ -8,9 +8,14 @@ import { normalizeFunnelStepPath } from '../app/utils/step-path'
8
8
  import {
9
9
  MAX_WEBSITE_DOMAIN_LENGTH,
10
10
  MAX_WEBSITE_NAME_LENGTH,
11
+ MODULE_CONFIG_KEY,
11
12
  } from '../app/constants'
12
13
 
13
14
  describe('front rules mirror the server', () => {
15
+ it('reads its public options where the backend puts them', () => {
16
+ expect(MODULE_CONFIG_KEY).toBe(server.FRONTEND_CONFIG_KEY)
17
+ })
18
+
14
19
  it('caps a funnel draft as the server does', () => {
15
20
  expect(draft.MAX_NAME_LENGTH).toBe(server.MAX_NAME_LENGTH)
16
21
  expect(draft.MAX_STEPS).toBe(server.MAX_FUNNEL_STEPS)
@@ -67,3 +67,10 @@ export function useDmsCookie<T>(
67
67
  ): { value: T } {
68
68
  return { value: options.default() }
69
69
  }
70
+
71
+ export function useDmsUserSession(): {
72
+ loggedIn: { value: boolean }
73
+ session: { value: { activeTenantId?: string } | null }
74
+ } {
75
+ return { loggedIn: { value: false }, session: { value: null } }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-marketing",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "First-party marketing analytics for the AntelopeJS DMS: traffic dashboards, conversion funnels with A/B splits and click heatmaps, without cookies or external services.",
5
5
  "keywords": [
6
6
  "ab-testing",
@@ -51,7 +51,7 @@
51
51
  "@antelopejs/interface-database": ">=0.1.10 <1.0.0",
52
52
  "@antelopejs/interface-database-decorators": ">=0.1.9 <1.0.0",
53
53
  "@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
54
- "@antelopejs/interface-dms-marketing": ">=0.4.1 <0.5.0",
54
+ "@antelopejs/interface-dms-marketing": ">=0.5.0 <0.6.0",
55
55
  "defu": "^6.1.4",
56
56
  "maxmind": "^5.0.7",
57
57
  "node-cron": "^4.2.1",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dms-marketing
3
- description: First-party, cookieless web analytics with the @antelopejs/dms-marketing module of the AntelopeJS DMS. Use when installing or configuring dms-marketing, embedding its tracker on a site, sending custom events or A/B variations from page code (dmsMarketing.track / variation), defining funnels or A/B tests, reading heatmaps from another module via GetClickHeatmap, or debugging missing, refused (403/404) or empty analytics.
3
+ description: First-party, cookieless web analytics with the @antelopejs/dms-marketing module of the AntelopeJS DMS. Use when installing or configuring dms-marketing, embedding its tracker on a site, sending custom events or A/B variations from page code (dmsMarketing.track / variation), defining funnels or A/B tests, shipping websites, funnels or demo/migrated history from code (EnsureWebsite / EnsureFunnel / ImportAnalytics), reading heatmaps from another module via GetClickHeatmap, or debugging missing, refused (403/404) or empty analytics.
4
4
  icon: i-lucide-chart-line
5
5
  category: analytics
6
6
  tags: [analytics, tracking, funnels, ab-testing, heatmaps, dms-module]
@@ -19,7 +19,8 @@ https://dms.antelopejs.com/docs/modules/marketing (`architecture` holds the full
19
19
  0.4 is the DMS 0.8 line.
20
20
  - `ajs project modules add @antelopejs/dms-marketing`. Options: `trackerEnabled`, `rawEventsRetention`,
21
21
  `statisticsRetention`, `snapshotRetention`, `sessionIdleTimeout` (all ms), `heatmapSampleRate` (0-1),
22
- `visitorHashSecret` (set it when running several instances), `geoipDatabasePath` (.mmdb, opt-in).
22
+ `visitorHashSecret` (set it when running several instances), `geoipDatabasePath` (.mmdb, opt-in),
23
+ `dashboardTracking` (see below).
23
24
  The Settings page overrides switch, retentions and sampling at runtime for the whole deployment.
24
25
  - Behind a proxy set the DMS `auth.trustedProxies`; the old `trustedProxyHops` option is gone.
25
26
  - One website row per tracked site (Websites page or `POST /api/marketing/websites {name, domain, extraDomains?}`).
@@ -41,6 +42,55 @@ const arm = await window.dmsMarketing?.variation("hero-cta"); // null = ren
41
42
  - Never `type="module"` (breaks `document.currentScript`). Always guard with `?.`.
42
43
  - Snapshots (heatmap backdrops) are opt-in per site; mask with `data-dms-marketing-mask` / `data-dms-marketing-block`.
43
44
 
45
+ ## Tracking the dashboard itself
46
+
47
+ For a demo, a playground or a back office measuring its own admin, no tag to write:
48
+
49
+ ```typescript
50
+ config: { dashboardTracking: { enabled: true, websiteName: "Dashboard", excludePaths: ["/settings/**"] } }
51
+ ```
52
+
53
+ - Off by default. On, the module's frontend loads the tracker on every signed-in dashboard page and
54
+ `POST /api/marketing/dashboard-tracking` creates one website per tenant on first use (domain = the
55
+ dashboard's host; later hosts join `extraDomains`). Rename, pause or delete it like any site.
56
+ - `/auth/**` is never counted; signing out pauses the tag, a workspace switch retargets it. SPA navigations
57
+ count. Restart `ajs dms dev` / rebuild the frontend after toggling the option.
58
+ - Tracker attributes behind it, usable on any site: `data-exclude-paths="/x,/x/*,/x/**"`, and a live
59
+ `data-website-id` (changing it retargets, removing it pauses).
60
+
61
+ ## Content from code
62
+
63
+ Never write the module's tables. From a module's `start()` (await) — or unawaited with a `.catch` in
64
+ `construct()` / a DATABASE_INITIALIZED handler — use `@antelopejs/interface-dms-marketing`:
65
+
66
+ ```typescript
67
+ const tenantId = "default"; // explicit, never implied
68
+ await EnsureWebsite({ tenantId, id: "shop-site", name: "Shop", domain: "shop.example.com",
69
+ snapshotsEnabled: true }); // id = data-website-id, global
70
+ await EnsureFunnel({ tenantId, id: "shop-quote", websiteId: "shop-site", name: "Quote",
71
+ steps: [{ kind: "url", value: "/pricing" }, { kind: "custom", value: "quote_submitted" }],
72
+ experiment: { key: "hero-cta", status: "running", startedAt: "2026-07-01T00:00:00Z",
73
+ variations: [{ key: "control", weight: 1 }, { key: "bold", weight: 1 }] } });
74
+ await EnsurePageSnapshot({ tenantId, websiteId: "shop-site", url: "https://shop.example.com/pricing",
75
+ html, viewport: { width: 1280, height: 800 }, document: { width: 1280, height: 2600 } });
76
+ await ImportAnalytics({ tenantId, importId: "shop-history", websiteId: "shop-site", sessions: [
77
+ { visitor: "v1", userAgent, country: "BE", events: [{ kind: "pageview", at: "2026-10-01T09:00:00Z", url }] }] });
78
+ ```
79
+
80
+ - Ensure* answer `created` / `unchanged` / `updated` / `kept`: a record someone changed since the last
81
+ declaration (hash stored on the row) is never overwritten; delete it in the dashboard to re-take it.
82
+ Dashboard rules apply (hostnames, 1-10 steps, 2-8 arms, draft → running ⇄ stopped, arms frozen out of draft).
83
+ - ImportAnalytics replays through the live collect pipeline with trusted timestamps (same sessions,
84
+ raw events and daily rows); once per importId + website, later calls `in_progress` (first still replaying, counts not final),
85
+ then `skipped` (or `failed`); ≤ 10,000 sessions and
86
+ 1,000 events/session per call; drops future, past-retention, invalid and bot events and exposures of
87
+ draft splits. `startedAt` on a running split makes imported exposures count.
88
+ - EnsurePageSnapshot: the heatmap backdrop of a page the tracker cannot capture (not online, imported
89
+ history); keyed by path + layout of `viewport` (desktop ≥ 1024, tablet ≥ 600, phone); sanitized like a
90
+ capture; needs `snapshotsEnabled`; a live capture replaces it and is never overwritten (`kept`).
91
+ - `tenantId` is required on every content call: no default. Full contract: the interface package's
92
+ `docs/1.index.md`.
93
+
44
94
  ## Other modules
45
95
 
46
96
  Tenant users cannot open module pages. To show analytics to them, build a project page from DMS blocks
package/static/tracker.js CHANGED
@@ -9,6 +9,14 @@
9
9
  * data-host-url override the collect origin (defaults to the script origin)
10
10
  * data-heatmap-sample 0-1 click/scroll sampling rate; overrides the module setting
11
11
  * data-do-not-track "false" to ignore the browser DNT and GPC signals
12
+ * data-exclude-paths comma-separated pages left uncounted: `/x` (that
13
+ * page), `/x/*` (one segment below) or `/x/**` (the
14
+ * page and everything below)
15
+ *
16
+ * The tag stays live: changing its `data-website-id` sends what follows to
17
+ * that website, and removing it pauses collection until one is set again.
18
+ * The dashboard's own tag relies on it to follow sign-out and workspace
19
+ * switches without a reload.
12
20
  *
13
21
  * Cookieless by design: no identifier is generated or stored client-side —
14
22
  * the backend derives an anonymous visitor hash per request. Payload shape
@@ -118,11 +126,8 @@
118
126
  return Math.min(Math.max(rate, 0), 1);
119
127
  }
120
128
 
121
- const websiteId = script.getAttribute("data-website-id");
122
- if (!websiteId) {
123
- publishApi();
124
- return;
125
- }
129
+ /** Null while the page removed it: nothing is collected meanwhile. */
130
+ let websiteId = script.getAttribute("data-website-id");
126
131
 
127
132
  // A tag included twice (theme and tag manager) would count everything twice.
128
133
  if (window[INSTALLED_GLOBAL]) {
@@ -151,6 +156,12 @@
151
156
  config,
152
157
  );
153
158
 
159
+ /** Patterns of `data-exclude-paths`, each a path starting with "/". */
160
+ const excludedPaths = (script.getAttribute("data-exclude-paths") || "")
161
+ .split(",")
162
+ .map((pattern) => pattern.trim())
163
+ .filter((pattern) => pattern.charAt(0) === "/");
164
+
154
165
  const heatmapSampled = Math.random() < sampleRate;
155
166
  const queue = [];
156
167
  let flushTimer = null;
@@ -193,6 +204,11 @@
193
204
  }
194
205
 
195
206
  function enqueue(event) {
207
+ // Attribute writes are synchronous; MutationObserver delivery is not.
208
+ // Until retargeting completes, never put new events in the old queue.
209
+ if (!websiteId || script.getAttribute("data-website-id") !== websiteId || pageExcluded) {
210
+ return;
211
+ }
196
212
  queue.push(event);
197
213
  if (queue.length >= MAX_BATCH) {
198
214
  flush();
@@ -203,9 +219,38 @@
203
219
  }
204
220
  }
205
221
 
222
+ // --- Excluded pages -------------------------------------------------------
223
+
224
+ function withoutTrailingSlash(path) {
225
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
226
+ }
227
+
228
+ function pathMatches(pattern, path) {
229
+ if (pattern.endsWith("/**")) {
230
+ const base = pattern.slice(0, -3);
231
+ return path === base || path.startsWith(`${base}/`);
232
+ }
233
+ if (pattern.endsWith("/*")) {
234
+ const base = pattern.slice(0, -2);
235
+ const rest = path.startsWith(`${base}/`)
236
+ ? path.slice(base.length + 1)
237
+ : "";
238
+ return rest !== "" && rest.indexOf("/") < 0;
239
+ }
240
+ return path === withoutTrailingSlash(pattern);
241
+ }
242
+
243
+ function isExcludedPath(pathname) {
244
+ const path = withoutTrailingSlash(pathname);
245
+ return excludedPaths.some((pattern) => pathMatches(pattern, path));
246
+ }
247
+
206
248
  // --- Pageviews (initial + SPA navigations) --------------------------------
207
249
 
208
250
  let lastPath = null;
251
+ /** The page on screen matches `data-exclude-paths`: nothing it does is
252
+ * sent, its clicks and scroll depth included. */
253
+ let pageExcluded = false;
209
254
  /** URL the scroll depth is being measured against — kept because a SPA
210
255
  * navigation rewrites `location` before the depth of the page being left
211
256
  * is flushed. */
@@ -219,8 +264,12 @@
219
264
  }
220
265
  flushScrollDepth();
221
266
  lastPath = location.pathname;
267
+ pageExcluded = isExcludedPath(lastPath);
222
268
  scrolledUrl = currentUrl();
223
269
  maxScrollDepth = -1;
270
+ if (!websiteId || pageExcluded) {
271
+ return;
272
+ }
224
273
  // The rate it applies, for the server's "recorded on N % of page loads":
225
274
  // counting scroll events against pageviews undercounts the views left
226
275
  // before their depth is sent.
@@ -440,6 +489,9 @@
440
489
  function requestSnapshot() {
441
490
  if (
442
491
  !heatmapSampled ||
492
+ !websiteId ||
493
+ script.getAttribute("data-website-id") !== websiteId ||
494
+ pageExcluded ||
443
495
  snapshotsRequested >= MAX_SNAPSHOTS_PER_LOAD ||
444
496
  window.top !== window ||
445
497
  document.visibilityState !== "visible" ||
@@ -562,7 +614,14 @@
562
614
  /** Assigned variation of a running experiment, or null. Reports the
563
615
  * exposure, so only call it where the variation is actually applied. */
564
616
  variation(experiment) {
617
+ const requestedWebsite = websiteId;
618
+ if (!requestedWebsite || script.getAttribute("data-website-id") !== requestedWebsite) {
619
+ return Promise.resolve(null);
620
+ }
565
621
  return loadAssignments().then((assignments) => {
622
+ if (websiteId !== requestedWebsite || script.getAttribute("data-website-id") !== requestedWebsite) {
623
+ return null;
624
+ }
566
625
  const assigned = assignments[experiment];
567
626
  if (typeof assigned !== "string") {
568
627
  return null;
@@ -590,5 +649,31 @@
590
649
  flush();
591
650
  });
592
651
 
652
+ /**
653
+ * The tag's website changed: what was collected so far leaves under the
654
+ * previous one, and the page on screen counts for the new one. The
655
+ * assignments were the previous website's experiments.
656
+ */
657
+ function followWebsiteId() {
658
+ const next = script.getAttribute("data-website-id") || null;
659
+ if (next === websiteId) {
660
+ return;
661
+ }
662
+ flushScrollDepth();
663
+ flush();
664
+ websiteId = next;
665
+ assignmentsPromise = null;
666
+ exposedExperiments.clear();
667
+ lastPath = null;
668
+ trackPageview();
669
+ }
670
+
671
+ if (typeof MutationObserver === "function") {
672
+ new MutationObserver(followWebsiteId).observe(script, {
673
+ attributes: true,
674
+ attributeFilter: ["data-website-id"],
675
+ });
676
+ }
677
+
593
678
  trackPageview();
594
679
  })();
@@ -1,2 +0,0 @@
1
- packages:
2
- - '.'