@hanzo/event 0.3.43 → 0.3.44

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.
package/src/tags.ts ADDED
@@ -0,0 +1,449 @@
1
+ // The tag manager: loads each ad and analytics platform's browser pixel, after
2
+ // consent, from the site's tag configuration, and fires every event to each one
3
+ // under one event_id.
4
+ //
5
+ // WHERE THE IDS COME FROM. Never from a site's code. The site's tag set is
6
+ // cloud's `GET /v1/project/tags` (non-secret ids only), so connecting a
7
+ // platform to a site is a configuration change and every surface gets it:
8
+ //
9
+ // { tags: [{ platform: 'ga4', type: 'ga', id: 'G-…' },
10
+ // { platform: 'linkedin', type: 'linkedin', id: '1234',
11
+ // events: { order_completed: '9876' } }, …] }
12
+ //
13
+ // `events` maps one of OUR event names to the platform's own conversion id for
14
+ // it: a Google Ads `AW-…/label`, a LinkedIn conversion rule, an X event id.
15
+ // Google Ads, LinkedIn and X name conversions by rule, not by event, and an
16
+ // event with no rule there is not sent to that platform.
17
+ //
18
+ // WHAT LOADS WHEN. Nothing loads before consent. Analytics allows GA4;
19
+ // Marketing allows Google Ads, Meta, LinkedIn, X and TikTok. A visitor who has
20
+ // not chosen has allowed what their region presumes (consent.ts), so an EU
21
+ // visitor's page makes no request to any platform until they accept. Google's
22
+ // Consent Mode v2 is set denied before gtag.js is fetched and updated on every
23
+ // choice. A choice that allows more loads the rest with no reload.
24
+ //
25
+ // ONE EVENT ID. `track` mints it, fires the browser pixels with it, and records
26
+ // it on our stream with the list of pixels that fired (`tags`). Cloud forwards
27
+ // the same moment server-side under the same id, and each platform keeps one
28
+ // (Meta and TikTok by event_id, GA4 by transaction_id on a purchase); it sends
29
+ // GA4 only what the page's own gtag did not, which is what `tags` says.
30
+ //
31
+ // WHAT EACH PLATFORM CALLS AN EVENT is the one table in @hanzo/events; cloud
32
+ // reads the same table.
33
+
34
+ import { namesOn } from '@hanzo/events'
35
+ import { CONSENT_EVENT, read, render, type Choice } from './consent'
36
+ import { capture, touch } from './touch'
37
+ import type { Analytics } from './core'
38
+
39
+ export interface BrowserTag {
40
+ platform: string
41
+ type: string
42
+ id: string
43
+ events?: Record<string, string>
44
+ }
45
+
46
+ export interface TagOptions {
47
+ /** The site's publishable key; resolves its tag set. */
48
+ key?: string
49
+ /** The site's host, for a site whose key is the org's. */
50
+ host?: string
51
+ /** cloud's origin; defaults to https://api.hanzo.ai. */
52
+ base?: string
53
+ /** Domains one visit crosses, so GA4 keeps it one session. */
54
+ domains?: string[]
55
+ }
56
+
57
+ type Call = (...args: unknown[]) => void
58
+ type Page = {
59
+ dataLayer?: unknown[]
60
+ gtag?: Call
61
+ fbq?: Call & { queue?: unknown[]; callMethod?: Call; push?: unknown; loaded?: boolean; version?: string }
62
+ _fbq?: unknown
63
+ lintrk?: Call & { q?: unknown[] }
64
+ _linkedin_data_partner_ids?: string[]
65
+ twq?: Call & { queue?: unknown[]; exe?: Call; version?: string }
66
+ ttq?: Record<string, Call> & { _i?: Record<string, unknown>; _t?: Record<string, number>; _o?: Record<string, unknown>; methods?: string[]; load?: Call; page?: Call; track?: Call }
67
+ TiktokAnalyticsObject?: string
68
+ }
69
+
70
+ const page = (): Page => window as unknown as Page
71
+
72
+ let tags: BrowserTag[] = []
73
+ let configured = false
74
+ let options: TagOptions = {}
75
+ const loaded = new Set<string>()
76
+
77
+ /** gtag.js: 'idle' until fetched, 'wait' while on its way, then how it answered. */
78
+ let google: 'idle' | 'wait' | 'loaded' | 'failed' = 'idle'
79
+ let configAnswered = false
80
+
81
+ const held: Array<() => void> = []
82
+ let leaving = false
83
+
84
+ function settled(): boolean {
85
+ return configAnswered && google !== 'wait'
86
+ }
87
+
88
+ function flush(): void {
89
+ if (settled() || leaving) held.splice(0).forEach((send) => send())
90
+ }
91
+
92
+ function script(src: string, onload?: () => void, onerror?: () => void): void {
93
+ const s = document.createElement('script')
94
+ s.async = true
95
+ s.src = src
96
+ if (onload) s.onload = onload
97
+ if (onerror) s.onerror = onerror
98
+ document.head.appendChild(s)
99
+ }
100
+
101
+ // ── Google ──────────────────────────────────────────────────────────────
102
+
103
+ function gtagStub(): Call {
104
+ const p = page()
105
+ p.dataLayer = p.dataLayer || []
106
+ if (!p.gtag) {
107
+ // gtag.js reads the `arguments` object itself, so this is a function and not an arrow.
108
+ p.gtag = function () {
109
+ // eslint-disable-next-line prefer-rest-params
110
+ p.dataLayer!.push(arguments)
111
+ }
112
+ }
113
+ return p.gtag
114
+ }
115
+
116
+ const g = (on: boolean) => (on ? 'granted' : 'denied')
117
+
118
+ /** Google Consent Mode v2: denied until said otherwise, then the visitor's choice. */
119
+ function consentMode(c: Choice, first: boolean): void {
120
+ const gtag = gtagStub()
121
+ const state = {
122
+ analytics_storage: g(c.analytics),
123
+ ad_storage: g(c.marketing),
124
+ ad_user_data: g(c.marketing),
125
+ ad_personalization: g(c.marketing && c.ads),
126
+ }
127
+ if (first) {
128
+ gtag('consent', 'default', {
129
+ analytics_storage: 'denied',
130
+ ad_storage: 'denied',
131
+ ad_user_data: 'denied',
132
+ ad_personalization: 'denied',
133
+ wait_for_update: 500,
134
+ })
135
+ }
136
+ gtag('consent', 'update', state)
137
+ }
138
+
139
+ function loadGoogle(ids: string[]): void {
140
+ const gtag = gtagStub()
141
+ const fresh = ids.filter((id) => !loaded.has(id))
142
+ if (!fresh.length) return
143
+ if (google === 'idle') {
144
+ google = 'wait'
145
+ gtag('js', new Date())
146
+ const done = (to: 'loaded' | 'failed') => () => {
147
+ if (google !== 'wait') return
148
+ google = to
149
+ flush()
150
+ }
151
+ script(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(fresh[0])}`, done('loaded'), done('failed'))
152
+ // gtag.js answers once: loaded, failed (a blocker), or silent past 8 s.
153
+ setTimeout(done('failed'), 8000)
154
+ }
155
+ for (const id of fresh) {
156
+ loaded.add(id)
157
+ gtag('config', id, options.domains ? { linker: { domains: options.domains } } : {})
158
+ }
159
+ }
160
+
161
+ // ── The other pixels ────────────────────────────────────────────────────
162
+
163
+ function loadMeta(id: string, c: Choice): void {
164
+ const p = page()
165
+ if (!p.fbq) {
166
+ const n = (p.fbq = function (...a: unknown[]) {
167
+ if (n.callMethod) n.callMethod(...a)
168
+ else n.queue!.push(a)
169
+ } as NonNullable<Page['fbq']>)
170
+ if (!p._fbq) p._fbq = n
171
+ n.push = n
172
+ n.loaded = true
173
+ n.version = '2.0'
174
+ n.queue = []
175
+ script('https://connect.facebook.net/en_US/fbevents.js')
176
+ }
177
+ if (!c.ads) p.fbq('dataProcessingOptions', ['LDU'], 0, 0)
178
+ p.fbq('init', id)
179
+ p.fbq('track', 'PageView')
180
+ }
181
+
182
+ function loadLinkedIn(id: string): void {
183
+ const p = page()
184
+ p._linkedin_data_partner_ids = p._linkedin_data_partner_ids || []
185
+ p._linkedin_data_partner_ids.push(id)
186
+ if (!p.lintrk) {
187
+ const l = (p.lintrk = function (...a: unknown[]) {
188
+ l.q!.push(a)
189
+ } as NonNullable<Page['lintrk']>)
190
+ l.q = []
191
+ }
192
+ script('https://snap.licdn.com/li.lms-analytics/insight.min.js')
193
+ }
194
+
195
+ function loadX(id: string): void {
196
+ const p = page()
197
+ if (!p.twq) {
198
+ const s = (p.twq = function (...a: unknown[]) {
199
+ if (s.exe) s.exe(...a)
200
+ else s.queue!.push(a)
201
+ } as NonNullable<Page['twq']>)
202
+ s.version = '1.1'
203
+ s.queue = []
204
+ script('https://static.ads-twitter.com/uwt.js')
205
+ }
206
+ p.twq('config', id)
207
+ }
208
+
209
+ const TIKTOK_METHODS = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie']
210
+
211
+ function loadTikTok(id: string): void {
212
+ const p = page()
213
+ p.TiktokAnalyticsObject = 'ttq'
214
+ const q = (p.ttq = p.ttq || ([] as unknown as NonNullable<Page['ttq']>)) as unknown as Record<string, unknown> & unknown[]
215
+ if (!q._i) {
216
+ q.methods = TIKTOK_METHODS
217
+ for (const m of TIKTOK_METHODS) q[m] = (...a: unknown[]) => q.push([m, ...a])
218
+ q._i = {}
219
+ q._t = {}
220
+ q._o = {}
221
+ q.load = (sdk: string) => {
222
+ ;(q._i as Record<string, unknown>)[sdk] = []
223
+ ;(q._t as Record<string, number>)[sdk] = Date.now()
224
+ script(`https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=${encodeURIComponent(sdk)}&lib=ttq`)
225
+ }
226
+ }
227
+ ;(q.load as Call)(id)
228
+ ;(q.page as Call)()
229
+ }
230
+
231
+ // ── Which tag is allowed and running ────────────────────────────────────
232
+
233
+ const NEEDS: Record<string, keyof Choice> = {
234
+ ga: 'analytics',
235
+ gads: 'marketing',
236
+ meta: 'marketing',
237
+ linkedin: 'marketing',
238
+ x: 'marketing',
239
+ tiktok: 'marketing',
240
+ }
241
+
242
+ /** GA4 also counts a visit when only Marketing is allowed (Consent Mode keeps it cookieless). */
243
+ const allowed = (t: BrowserTag, c: Choice): boolean =>
244
+ t.type === 'ga' ? c.analytics || c.marketing : Boolean(NEEDS[t.type] && c[NEEDS[t.type]])
245
+
246
+ function apply(): void {
247
+ if (typeof window === 'undefined' || !configured) return
248
+ const c = read()
249
+ capture(c)
250
+ const on = tags.filter((t) => allowed(t, c))
251
+ const ids = on.filter((t) => t.type === 'ga' || t.type === 'gads').map((t) => t.id)
252
+ if (ids.length) {
253
+ consentMode(c, !loaded.has('consent'))
254
+ loaded.add('consent')
255
+ loadGoogle(ids)
256
+ }
257
+ for (const t of on) {
258
+ const k = `${t.type}:${t.id}`
259
+ if (loaded.has(k)) continue
260
+ if (t.type === 'meta') loadMeta(t.id, c)
261
+ else if (t.type === 'linkedin') loadLinkedIn(t.id)
262
+ else if (t.type === 'x') loadX(t.id)
263
+ else if (t.type === 'tiktok') loadTikTok(t.id)
264
+ else continue
265
+ loaded.add(k)
266
+ }
267
+ flush()
268
+ }
269
+
270
+ /**
271
+ * Starts the tag manager: fetches the site's tag set and loads what consent
272
+ * allows, again on every consent change. Safe to call on every page load, and
273
+ * a no-op on the server. Returns a function that stops listening.
274
+ */
275
+ export function start(o: TagOptions = {}): () => void {
276
+ if (typeof window === 'undefined') return () => undefined
277
+ options = o
278
+ addEventListener(CONSENT_EVENT, apply)
279
+ addEventListener('pagehide', pagehide)
280
+ if (!configured) {
281
+ configured = true
282
+ const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
283
+ const q = new URLSearchParams()
284
+ if (o.key) q.set('key', o.key)
285
+ q.set('host', o.host ?? window.location.hostname)
286
+ const answer = (list: BrowserTag[]) => {
287
+ tags = list
288
+ configAnswered = true
289
+ apply()
290
+ }
291
+ // A page never waits on its tag config: an unreachable cloud is an empty set.
292
+ const cap = setTimeout(() => answer([]), 3000)
293
+ fetch(`${base}/v1/project/tags?${q}`)
294
+ .then((r) => (r.ok ? r.json() : { tags: [] }))
295
+ .then((j: { tags?: BrowserTag[] }) => {
296
+ clearTimeout(cap)
297
+ answer(Array.isArray(j.tags) ? j.tags : [])
298
+ })
299
+ .catch(() => {
300
+ clearTimeout(cap)
301
+ answer([])
302
+ })
303
+ }
304
+ return () => {
305
+ removeEventListener(CONSENT_EVENT, apply)
306
+ removeEventListener('pagehide', pagehide)
307
+ }
308
+ }
309
+
310
+ function pagehide(): void {
311
+ leaving = true
312
+ flush()
313
+ }
314
+
315
+ /** The browser tags a moment can reach right now, as the `tags` property spells it. */
316
+ export function reach(): string {
317
+ const c = read()
318
+ const to: string[] = []
319
+ for (const t of tags) {
320
+ if (!allowed(t, c)) continue
321
+ const running =
322
+ t.type === 'ga'
323
+ ? google === 'loaded' && c.analytics
324
+ : t.type === 'gads'
325
+ ? google === 'loaded' && c.marketing
326
+ : loaded.has(`${t.type}:${t.id}`)
327
+ if (running && !to.includes(t.type)) to.push(t.type)
328
+ }
329
+ return to.join(',')
330
+ }
331
+
332
+ // ── Firing an event ─────────────────────────────────────────────────────
333
+
334
+ interface Item {
335
+ item_id: string
336
+ item_name?: string
337
+ item_category?: string
338
+ item_variant?: string
339
+ price?: number
340
+ quantity?: number
341
+ }
342
+
343
+ const META_STANDARD = new Set([
344
+ 'AddPaymentInfo', 'AddToCart', 'AddToWishlist', 'CompleteRegistration', 'Contact', 'CustomizeProduct',
345
+ 'Donate', 'FindLocation', 'InitiateCheckout', 'Lead', 'Purchase', 'Schedule', 'Search', 'StartTrial',
346
+ 'SubmitApplication', 'Subscribe', 'ViewContent',
347
+ ])
348
+
349
+ function shape(platform: 'meta' | 'tiktok' | 'x', p: Record<string, unknown>): Record<string, unknown> {
350
+ const items = Array.isArray(p.items) ? (p.items as Item[]) : []
351
+ const out: Record<string, unknown> = {}
352
+ if (typeof p.value === 'number') out.value = p.value
353
+ out.currency = typeof p.currency === 'string' ? p.currency : 'USD'
354
+ if (!items.length) return out
355
+ if (platform === 'meta') {
356
+ out.content_type = 'product'
357
+ out.content_ids = items.map((i) => i.item_id)
358
+ out.contents = items.map((i) => ({ id: i.item_id, quantity: i.quantity ?? 1, item_price: i.price }))
359
+ out.content_name = items[0].item_name
360
+ out.num_items = items.reduce((n, i) => n + (i.quantity ?? 1), 0)
361
+ } else if (platform === 'tiktok') {
362
+ out.content_type = 'product'
363
+ out.contents = items.map((i) => ({ content_id: i.item_id, content_name: i.item_name, quantity: i.quantity ?? 1, price: i.price }))
364
+ } else {
365
+ out.contents = items.map((i) => ({ content_id: i.item_id, content_name: i.item_name, content_price: i.price, num_items: i.quantity ?? 1 }))
366
+ }
367
+ return out
368
+ }
369
+
370
+ /** A test order states no amount: nothing sent for it can be summed into revenue. */
371
+ function unpriced(p: Record<string, unknown>): Record<string, unknown> {
372
+ const out: Record<string, unknown> = { ...p, value: 0 }
373
+ if (Array.isArray(p.items)) out.items = (p.items as Item[]).map((i) => ({ ...i, price: 0 }))
374
+ return out
375
+ }
376
+
377
+ /**
378
+ * Sends one moment to the browser tags named in `to`, under `id`. The names each
379
+ * platform knows it by are the table in @hanzo/events; a platform the table
380
+ * gives no name, or a rule-named platform with no rule for it, is not sent it.
381
+ */
382
+ export function mirror(name: string, params: Record<string, unknown>, id: string, to = reach()): void {
383
+ const p = page()
384
+ const on = new Set(to.split(',').filter(Boolean))
385
+ const ga4 = namesOn(name, 'ga4')
386
+ const test = params.test === true
387
+ if (test) params = unpriced(params)
388
+ const transaction = typeof params.order_id === 'string' ? { transaction_id: params.order_id } : {}
389
+ for (const t of tags) {
390
+ if (!on.has(t.type)) continue
391
+ const rule = t.events?.[name]
392
+ if (t.type === 'ga') {
393
+ for (const n of ga4) {
394
+ p.gtag?.('event', n, { ...params, ...transaction, event_id: id, transport_type: 'beacon', ...(test ? { debug_mode: true } : {}) })
395
+ }
396
+ } else if (t.type === 'gads' && rule) {
397
+ p.gtag?.('event', 'conversion', {
398
+ send_to: rule,
399
+ value: typeof params.value === 'number' ? params.value : undefined,
400
+ currency: typeof params.currency === 'string' ? params.currency : 'USD',
401
+ ...transaction,
402
+ })
403
+ } else if (t.type === 'meta') {
404
+ for (const n of namesOn(name, 'meta')) {
405
+ p.fbq?.(META_STANDARD.has(n) ? 'track' : 'trackCustom', n, shape('meta', params), { eventID: id })
406
+ }
407
+ } else if (t.type === 'tiktok') {
408
+ for (const n of namesOn(name, 'tiktok')) p.ttq?.track?.(n, shape('tiktok', params), { event_id: id })
409
+ } else if (t.type === 'linkedin' && rule && namesOn(name, 'linkedin').length) {
410
+ p.lintrk?.('track', { conversion_id: Number(rule) })
411
+ } else if (t.type === 'x' && rule && namesOn(name, 'x').length) {
412
+ p.twq?.('event', rule, { ...shape('x', params), conversion_id: id })
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * One moment, to every place it is counted, under one event_id: our stream
419
+ * always hears it (with the consent, the click and the browser ids cloud
420
+ * forwards on), and each browser tag that is running hears it too. A moment
421
+ * that arrives while gtag.js is still on its way waits for it, so GA4 sees it
422
+ * with a session; a page that is leaving sends what it holds as things stand.
423
+ */
424
+ export function track(stream: Analytics | undefined, name: string, params: Record<string, unknown> = {}): void {
425
+ if (typeof window === 'undefined') return
426
+ if (!leaving && !settled()) {
427
+ held.push(() => track(stream, name, params))
428
+ return
429
+ }
430
+ const c = read()
431
+ const event_id = crypto.randomUUID()
432
+ const to = reach()
433
+ mirror(name, params, event_id, to)
434
+ stream?.capture(name, {
435
+ ...params,
436
+ ...touch(c),
437
+ event_id,
438
+ consent: render(c),
439
+ tags: to,
440
+ userAgent: navigator.userAgent,
441
+ url: window.location.href,
442
+ })
443
+ }
444
+
445
+ /** What cloud needs to forward a moment a page states itself, as if the browser had sent it. */
446
+ export function visit(): Record<string, unknown> {
447
+ const c = read()
448
+ return { ...touch(c), consent: render(c), tags: reach(), userAgent: navigator.userAgent, url: window.location.href }
449
+ }
package/src/touch.ts ADDED
@@ -0,0 +1,68 @@
1
+ // The ad click a visitor arrived on, kept first-party so a conversion days later
2
+ // is credited to it even when the ad tags never loaded.
3
+ //
4
+ // On every page load the click ids in the address are read: gclid, gbraid and
5
+ // wbraid (Google), fbclid (Meta), li_fat_id (LinkedIn), twclid (X), ttclid
6
+ // (TikTok). When any is there the whole set replaces the last one in `hz_touch`
7
+ // with the time it was captured: the last click is the one credited. It is kept
8
+ // 90 days, the lookback cloud applies before it offers a click to a platform
9
+ // (apps/destination/touch.go).
10
+ //
11
+ // Beside it are the browser ids the platforms match on. `_fbc` is Meta's click
12
+ // cookie, built from fbclid in the shape the Pixel would write it; `_fbp` is its
13
+ // browser id. `cid` is GA4's client id: the last two fields of `_ga`, or a
14
+ // first-party `hz_cid` of the same shape when gtag.js never ran.
15
+ //
16
+ // Nothing here runs without consent: the click and the Meta ids need Marketing,
17
+ // the client id needs Analytics.
18
+
19
+ import type { Choice } from './consent'
20
+ import { get, set } from './cookie'
21
+
22
+ export const CLICK_IDS = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'li_fat_id', 'twclid', 'ttclid'] as const
23
+
24
+ const DAYS = 90
25
+ const now = () => Math.floor(Date.now() / 1000)
26
+ const rand = () => Math.floor(Math.random() * 2 ** 31)
27
+
28
+ /** Records this page's click and the browser ids. Call once per page load. */
29
+ export function capture(c: Choice): void {
30
+ if (typeof window === 'undefined') return
31
+ if (c.marketing) {
32
+ const q = new URLSearchParams(window.location.search)
33
+ const click: Record<string, string | number> = {}
34
+ for (const k of CLICK_IDS) {
35
+ const v = q.get(k)?.trim()
36
+ if (v) click[k] = v
37
+ }
38
+ if (Object.keys(click).length) {
39
+ click.clicked = now()
40
+ set('hz_touch', JSON.stringify(click), DAYS)
41
+ if (click.fbclid) set('_fbc', `fb.1.${Date.now()}.${click.fbclid}`, DAYS)
42
+ }
43
+ if (!get('_fbp')) set('_fbp', `fb.1.${Date.now()}.${rand()}`, DAYS)
44
+ }
45
+ if (c.analytics && !get('_ga') && !get('hz_cid')) set('hz_cid', `${rand()}.${now()}`, 730)
46
+ }
47
+
48
+ /** The properties an event carries for cloud to match it on. */
49
+ export function touch(c: Choice): Record<string, string | number> {
50
+ const out: Record<string, string | number> = {}
51
+ if (c.marketing) {
52
+ try {
53
+ Object.assign(out, JSON.parse(get('hz_touch') ?? '{}'))
54
+ } catch {
55
+ /* a cookie that does not parse names no click */
56
+ }
57
+ const fbc = get('_fbc')
58
+ const fbp = get('_fbp')
59
+ if (fbc) out.fbc = fbc
60
+ if (fbp) out.fbp = fbp
61
+ }
62
+ if (c.analytics) {
63
+ const ga = get('_ga')?.split('.')
64
+ const cid = ga && ga.length >= 4 ? ga.slice(-2).join('.') : get('hz_cid')
65
+ if (cid) out.cid = cid
66
+ }
67
+ return out
68
+ }