@antelopejs/dms-marketing 0.5.0 → 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 (132) hide show
  1. package/README.md +57 -131
  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 +2 -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_events.model.d.ts +1 -0
  12. package/dist/db/models/marketing_imports.model.d.ts +27 -0
  13. package/dist/db/models/marketing_imports.model.js +38 -0
  14. package/dist/db/models/marketing_imports.model.js.map +1 -0
  15. package/dist/db/models/marketing_preferences.model.d.ts +1 -0
  16. package/dist/db/models/marketing_sessions.model.d.ts +1 -0
  17. package/dist/db/models/marketing_settings.model.d.ts +1 -0
  18. package/dist/db/models/page_snapshots.model.d.ts +2 -0
  19. package/dist/db/models/page_snapshots.model.js +14 -0
  20. package/dist/db/models/page_snapshots.model.js.map +1 -1
  21. package/dist/db/models/website_statistics.model.d.ts +1 -0
  22. package/dist/db/models/websites.model.d.ts +2 -0
  23. package/dist/db/models/websites.model.js +29 -0
  24. package/dist/db/models/websites.model.js.map +1 -1
  25. package/dist/db/tables/funnels.table.d.ts +1 -0
  26. package/dist/db/tables/funnels.table.js +4 -0
  27. package/dist/db/tables/funnels.table.js.map +1 -1
  28. package/dist/db/tables/index.d.ts +1 -0
  29. package/dist/db/tables/index.js +1 -0
  30. package/dist/db/tables/index.js.map +1 -1
  31. package/dist/db/tables/marketing_imports.table.d.ts +15 -0
  32. package/dist/db/tables/marketing_imports.table.js +63 -0
  33. package/dist/db/tables/marketing_imports.table.js.map +1 -0
  34. package/dist/db/tables/page_snapshots.table.d.ts +1 -0
  35. package/dist/db/tables/page_snapshots.table.js +4 -0
  36. package/dist/db/tables/page_snapshots.table.js.map +1 -1
  37. package/dist/db/tables/websites.table.d.ts +1 -0
  38. package/dist/db/tables/websites.table.js +4 -0
  39. package/dist/db/tables/websites.table.js.map +1 -1
  40. package/dist/hooks/index.js +1 -0
  41. package/dist/hooks/index.js.map +1 -1
  42. package/dist/implementations/dms-marketing/index.d.ts +5 -1
  43. package/dist/implementations/dms-marketing/index.js +25 -0
  44. package/dist/implementations/dms-marketing/index.js.map +1 -1
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +4 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/routes/dashboard-tracking.d.ts +9 -0
  49. package/dist/routes/dashboard-tracking.js +57 -0
  50. package/dist/routes/dashboard-tracking.js.map +1 -0
  51. package/dist/routes/index.d.ts +1 -0
  52. package/dist/routes/index.js +1 -0
  53. package/dist/routes/index.js.map +1 -1
  54. package/dist/routes/stats.js.map +1 -1
  55. package/dist/routes/websites.js +12 -25
  56. package/dist/routes/websites.js.map +1 -1
  57. package/dist/services/analytics-import.d.ts +2 -0
  58. package/dist/services/analytics-import.js +294 -0
  59. package/dist/services/analytics-import.js.map +1 -0
  60. package/dist/services/collect.d.ts +20 -0
  61. package/dist/services/collect.js +43 -17
  62. package/dist/services/collect.js.map +1 -1
  63. package/dist/services/context.d.ts +8 -3
  64. package/dist/services/context.js +21 -7
  65. package/dist/services/context.js.map +1 -1
  66. package/dist/services/dashboard-tracking.d.ts +25 -0
  67. package/dist/services/dashboard-tracking.js +85 -0
  68. package/dist/services/dashboard-tracking.js.map +1 -0
  69. package/dist/services/dashboard.js +7 -3
  70. package/dist/services/dashboard.js.map +1 -1
  71. package/dist/services/declarations.d.ts +11 -0
  72. package/dist/services/declarations.js +64 -0
  73. package/dist/services/declarations.js.map +1 -0
  74. package/dist/services/declared-funnel.d.ts +2 -0
  75. package/dist/services/declared-funnel.js +230 -0
  76. package/dist/services/declared-funnel.js.map +1 -0
  77. package/dist/services/declared-snapshot.d.ts +2 -0
  78. package/dist/services/declared-snapshot.js +113 -0
  79. package/dist/services/declared-snapshot.js.map +1 -0
  80. package/dist/services/declared-website.d.ts +2 -0
  81. package/dist/services/declared-website.js +118 -0
  82. package/dist/services/declared-website.js.map +1 -0
  83. package/dist/services/geoip.js.map +1 -1
  84. package/dist/services/index.d.ts +1 -0
  85. package/dist/services/index.js +1 -0
  86. package/dist/services/index.js.map +1 -1
  87. package/dist/services/readiness.d.ts +2 -0
  88. package/dist/services/readiness.js +15 -0
  89. package/dist/services/readiness.js.map +1 -0
  90. package/dist/services/sessionize.d.ts +1 -0
  91. package/dist/services/sessionize.js +3 -0
  92. package/dist/services/sessionize.js.map +1 -1
  93. package/dist/services/snapshots.d.ts +3 -0
  94. package/dist/services/snapshots.js +6 -0
  95. package/dist/services/snapshots.js.map +1 -1
  96. package/dist/services/tenant-website.js +1 -0
  97. package/dist/services/tenant-website.js.map +1 -1
  98. package/dist/services/website-report.js +4 -3
  99. package/dist/services/website-report.js.map +1 -1
  100. package/dist/services/website-rules.d.ts +8 -0
  101. package/dist/services/website-rules.js +34 -0
  102. package/dist/services/website-rules.js.map +1 -0
  103. package/dist/types/constants.d.ts +3 -0
  104. package/dist/types/constants.js +5 -2
  105. package/dist/types/constants.js.map +1 -1
  106. package/dist/types/snapshots.d.ts +55 -0
  107. package/dist/types/snapshots.js +8 -1
  108. package/dist/types/snapshots.js.map +1 -1
  109. package/frontend-vue/app/components/Context.vue +5 -1
  110. package/frontend-vue/app/components/FirstRun.vue +1 -1
  111. package/frontend-vue/app/components/PagePreview.vue +1 -0
  112. package/frontend-vue/app/components/WebsitesGrid.vue +6 -2
  113. package/frontend-vue/app/composables/useMarketingApi.ts +12 -0
  114. package/frontend-vue/app/composables/useMarketingContext.ts +2 -1
  115. package/frontend-vue/app/composables/useMarketingReads.ts +6 -0
  116. package/frontend-vue/app/constants.ts +6 -0
  117. package/frontend-vue/app/plugins/dashboard-tracking.client.ts +44 -0
  118. package/frontend-vue/app/utils/dashboard-tracker.ts +127 -0
  119. package/frontend-vue/app/utils/format.ts +6 -3
  120. package/frontend-vue/dms.frontend.ts +10 -0
  121. package/frontend-vue/i18n/locales/marketing-en-GB.json +2 -0
  122. package/frontend-vue/i18n/locales/marketing-fr-FR.json +2 -0
  123. package/frontend-vue/package.json +1 -1
  124. package/frontend-vue/tests/dashboard-tracking.test.ts +219 -0
  125. package/frontend-vue/tests/format.test.ts +9 -0
  126. package/frontend-vue/tests/marketing-reads.test.ts +23 -0
  127. package/frontend-vue/tests/server-parity.test.ts +5 -0
  128. package/frontend-vue/tests/stubs/frontend-module.ts +7 -0
  129. package/package.json +34 -15
  130. package/skills/dms-marketing/SKILL.md +107 -0
  131. package/static/tracker.js +90 -5
  132. package/frontend-vue/pnpm-workspace.yaml +0 -2
@@ -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!')
@@ -205,6 +205,7 @@ describe('the page preview', () => {
205
205
  path: '/pricing',
206
206
  layout: 'phone',
207
207
  enabled,
208
+ maskText: false,
208
209
  })
209
210
  const [snapshot] = calls
210
211
  expect(snapshot?.url()).toBeNull()
@@ -216,6 +217,28 @@ describe('the page preview', () => {
216
217
  layout: 'phone',
217
218
  })
218
219
  })
220
+
221
+ it('reads the capture again once the text mask changes, which discards it', () => {
222
+ const maskText = ref(false)
223
+ usePageSnapshot({
224
+ websiteId: 'acme-shop',
225
+ path: '/pricing',
226
+ layout: 'desktop',
227
+ enabled: true,
228
+ maskText,
229
+ })
230
+ const [snapshot] = calls
231
+ // Same URL and query: only a watched source reads it again.
232
+ const sources = snapshot?.options.watch as (() => unknown)[] | undefined
233
+ expect(sources?.map((source) => source())).toEqual([false])
234
+ maskText.value = true
235
+ expect(sources?.map((source) => source())).toEqual([true])
236
+ expect(query(snapshot)).toEqual({
237
+ website: 'acme-shop',
238
+ path: '/pricing',
239
+ layout: 'desktop',
240
+ })
241
+ })
219
242
  })
220
243
 
221
244
  describe('the reads a dialog makes when it asks', () => {
@@ -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,16 +1,32 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-marketing",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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
+ "keywords": [
6
+ "ab-testing",
7
+ "analytics",
8
+ "antelopejs",
9
+ "dms",
10
+ "dms-module",
11
+ "heatmap",
12
+ "marketing"
13
+ ],
14
+ "homepage": "https://dms.antelopejs.com/docs/modules/marketing",
15
+ "bugs": {
16
+ "url": "https://github.com/AntelopeJS/dms-marketing/issues"
17
+ },
4
18
  "license": "Apache-2.0",
5
19
  "repository": {
6
20
  "type": "git",
7
- "url": "git+https://github.com/AntelopeJS/dms-marketing.git"
21
+ "url": "git+https://github.com/AntelopeJS/dms-marketing.git",
22
+ "directory": "packages/dms-marketing"
8
23
  },
9
24
  "files": [
10
25
  "LICENSE",
11
26
  "dist",
12
27
  "!dist/.tsbuildinfo",
13
28
  "frontend-vue",
29
+ "skills",
14
30
  "static"
15
31
  ],
16
32
  "main": "dist/index.js",
@@ -28,14 +44,14 @@
28
44
  "registry": "https://registry.npmjs.org/"
29
45
  },
30
46
  "dependencies": {
31
- "@antelopejs/interface-api": ">=0.0.13 <1.0.0",
32
- "@antelopejs/interface-api-util": ">=0.1.2 <1.0.0",
33
- "@antelopejs/interface-core": ">=0.1.1 <1.0.0",
34
- "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
35
- "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
36
- "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
37
- "@antelopejs/interface-dms": ">=0.7.0 <1.0.0",
38
- "@antelopejs/interface-dms-marketing": ">=0.4.0 <0.5.0",
47
+ "@antelopejs/interface-api": ">=0.0.15 <1.0.0",
48
+ "@antelopejs/interface-api-util": ">=0.1.3 <1.0.0",
49
+ "@antelopejs/interface-core": ">=0.2.4 <1.0.0",
50
+ "@antelopejs/interface-data-api": ">=0.2.2 <1.0.0",
51
+ "@antelopejs/interface-database": ">=0.1.10 <1.0.0",
52
+ "@antelopejs/interface-database-decorators": ">=0.1.9 <1.0.0",
53
+ "@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
54
+ "@antelopejs/interface-dms-marketing": ">=0.5.0 <0.6.0",
39
55
  "defu": "^6.1.4",
40
56
  "maxmind": "^5.0.7",
41
57
  "node-cron": "^4.2.1",
@@ -44,10 +60,10 @@
44
60
  "zod": "~3.24.4"
45
61
  },
46
62
  "devDependencies": {
47
- "@antelopejs/core": ">=1.13.5 <2",
48
- "@antelopejs/interface-mongodb": ">=0.0.5 <1.0.0",
49
- "@antelopejs/mongodb": "1.4.2",
50
- "@antelopejs/tooling-configs": ">=0.0.7 <1.0.0",
63
+ "@antelopejs/core": ">=1.16.0 <2",
64
+ "@antelopejs/interface-mongodb": ">=0.0.6 <1.0.0",
65
+ "@antelopejs/mongodb": "1.4.3",
66
+ "@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
51
67
  "@types/node": "^22.19.15",
52
68
  "@types/sanitize-html": "^2.16.2",
53
69
  "knip": "^6.34.0",
@@ -71,7 +87,10 @@
71
87
  "@/*": [
72
88
  "*"
73
89
  ]
74
- }
90
+ },
91
+ "skills": [
92
+ "./skills"
93
+ ]
75
94
  },
76
95
  "scripts": {
77
96
  "build": "pnpm --filter @antelopejs/interface-dms-marketing build && rimraf dist && tsc",
@@ -0,0 +1,107 @@
1
+ ---
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, 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
+ icon: i-lucide-chart-line
5
+ category: analytics
6
+ tags: [analytics, tracking, funnels, ab-testing, heatmaps, dms-module]
7
+ ---
8
+
9
+ # dms-marketing
10
+
11
+ Traffic dashboards, funnels with optional A/B splits and click heatmaps, all stored in the project's MongoDB.
12
+ Pages under `/modules/marketing` (owner-only): overview, acquisition, pages (heatmaps), funnels, funnel,
13
+ funnel-builder, websites, install, settings. User docs: `docs/` of this package, published at
14
+ https://dms.antelopejs.com/docs/modules/marketing (`architecture` holds the full HTTP and event contracts).
15
+
16
+ ## Setup
17
+
18
+ - Marketing 0.5 requires `@antelopejs/dms` >=0.9 (interface-dms 0.7, renderer `@antelopejs/dms-frontend` 0.6);
19
+ 0.4 is the DMS 0.8 line.
20
+ - `ajs project modules add @antelopejs/dms-marketing`. Options: `trackerEnabled`, `rawEventsRetention`,
21
+ `statisticsRetention`, `snapshotRetention`, `sessionIdleTimeout` (all ms), `heatmapSampleRate` (0-1),
22
+ `visitorHashSecret` (set it when running several instances), `geoipDatabasePath` (.mmdb, opt-in),
23
+ `dashboardTracking` (see below).
24
+ The Settings page overrides switch, retentions and sampling at runtime for the whole deployment.
25
+ - Behind a proxy set the DMS `auth.trustedProxies`; the old `trustedProxyHops` option is gone.
26
+ - One website row per tracked site (Websites page or `POST /api/marketing/websites {name, domain, extraDomains?}`).
27
+ Its `domain` gates ingestion: the beacon's Origin must be that apex, a subdomain or an extra host.
28
+
29
+ ## Visitor site
30
+
31
+ ```html
32
+ <script defer src="https://<dashboard>/api/marketing/tracker.js" data-website-id="<id>"></script>
33
+ ```
34
+
35
+ ```javascript
36
+ window.dmsMarketing?.track("signup_submitted", { plan: "pro" }); // name <= 50 chars, data <= 4 KiB
37
+ const arm = await window.dmsMarketing?.variation("hero-cta"); // null = render the control
38
+ ```
39
+
40
+ - The dashboard origin forwards the scripts and beacons because the module declares them `publicEndpoints`
41
+ (renderer 0.6 refuses other cross-origin writes); the backend origin works too.
42
+ - Never `type="module"` (breaks `document.currentScript`). Always guard with `?.`.
43
+ - Snapshots (heatmap backdrops) are opt-in per site; mask with `data-dms-marketing-mask` / `data-dms-marketing-block`.
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
+
94
+ ## Other modules
95
+
96
+ Tenant users cannot open module pages. To show analytics to them, build a project page from DMS blocks
97
+ (custom block only for the overlay, with `.meta()`), and read data through the optional interface:
98
+ `GetClickHeatmap({ tenantId, path, from?, to?, websiteId? })` from `@antelopejs/interface-dms-marketing`
99
+ (declare it in `optionalDependencies`; calls reject without an implementer).
100
+
101
+ ## Gotchas
102
+
103
+ - Collect `403` = host not declared on the website; `404` = unknown website or its Collect data is off;
104
+ all events `dropped` = collection paused in Settings.
105
+ - The tracker script is cached 1 h: settings reach returning visitors within that window.
106
+ - Funnels and A/B results read raw events (default 90 days); dashboards read daily rollups only.
107
+ - Funnel steps are page paths or custom event names, matched in order within one session and window.
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
- - '.'