@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/dist/consent.d.ts +36 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/cookie.d.ts +5 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.cjs +477 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +459 -38
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/tags.d.ts +42 -0
- package/dist/tags.d.ts.map +1 -0
- package/dist/touch.d.ts +7 -0
- package/dist/touch.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/consent.test.ts +140 -0
- package/src/consent.ts +116 -0
- package/src/cookie.ts +28 -0
- package/src/events.ts +1 -1
- package/src/index.ts +8 -0
- package/src/tags.test.ts +187 -0
- package/src/tags.ts +449 -0
- package/src/touch.ts +68 -0
package/dist/tags.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Analytics } from './core';
|
|
2
|
+
export interface BrowserTag {
|
|
3
|
+
platform: string;
|
|
4
|
+
type: string;
|
|
5
|
+
id: string;
|
|
6
|
+
events?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface TagOptions {
|
|
9
|
+
/** The site's publishable key; resolves its tag set. */
|
|
10
|
+
key?: string;
|
|
11
|
+
/** The site's host, for a site whose key is the org's. */
|
|
12
|
+
host?: string;
|
|
13
|
+
/** cloud's origin; defaults to https://api.hanzo.ai. */
|
|
14
|
+
base?: string;
|
|
15
|
+
/** Domains one visit crosses, so GA4 keeps it one session. */
|
|
16
|
+
domains?: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Starts the tag manager: fetches the site's tag set and loads what consent
|
|
20
|
+
* allows, again on every consent change. Safe to call on every page load, and
|
|
21
|
+
* a no-op on the server. Returns a function that stops listening.
|
|
22
|
+
*/
|
|
23
|
+
export declare function start(o?: TagOptions): () => void;
|
|
24
|
+
/** The browser tags a moment can reach right now, as the `tags` property spells it. */
|
|
25
|
+
export declare function reach(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Sends one moment to the browser tags named in `to`, under `id`. The names each
|
|
28
|
+
* platform knows it by are the table in @hanzo/events; a platform the table
|
|
29
|
+
* gives no name, or a rule-named platform with no rule for it, is not sent it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mirror(name: string, params: Record<string, unknown>, id: string, to?: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* One moment, to every place it is counted, under one event_id: our stream
|
|
34
|
+
* always hears it (with the consent, the click and the browser ids cloud
|
|
35
|
+
* forwards on), and each browser tag that is running hears it too. A moment
|
|
36
|
+
* that arrives while gtag.js is still on its way waits for it, so GA4 sees it
|
|
37
|
+
* with a session; a page that is leaving sends what it holds as things stand.
|
|
38
|
+
*/
|
|
39
|
+
export declare function track(stream: Analytics | undefined, name: string, params?: Record<string, unknown>): void;
|
|
40
|
+
/** What cloud needs to forward a moment a page states itself, as if the browser had sent it. */
|
|
41
|
+
export declare function visit(): Record<string, unknown>;
|
|
42
|
+
//# sourceMappingURL=tags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAA;AAEvC,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAuND;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAE,UAAe,GAAG,MAAM,IAAI,CAiCpD;AAOD,uFAAuF;AACvF,wBAAgB,KAAK,IAAI,MAAM,CAc9B;AA+CD;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,SAAU,GAAG,IAAI,CAiCpG;AAED;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,SAAS,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,IAAI,CAmB7G;AAED,gGAAgG;AAChG,wBAAgB,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG/C"}
|
package/dist/touch.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Choice } from './consent';
|
|
2
|
+
export declare const CLICK_IDS: readonly ['gclid', 'gbraid', 'wbraid', 'fbclid', 'li_fat_id', 'twclid', 'ttclid'];
|
|
3
|
+
/** Records this page's click and the browser ids. Call once per page load. */
|
|
4
|
+
export declare function capture(c: Choice): void;
|
|
5
|
+
/** The properties an event carries for cloud to match it on. */
|
|
6
|
+
export declare function touch(c: Choice): Record<string, string | number>;
|
|
7
|
+
//# sourceMappingURL=touch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"touch.d.ts","sourceRoot":"","sources":["../src/touch.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAGvC,eAAO,MAAM,SAAS,YAAI,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,CAAU,CAAA;AAM1G,8EAA8E;AAC9E,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAiBvC;AAED,gEAAgE;AAChE,wBAAgB,KAAK,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAmBhE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.44",
|
|
4
4
|
"description": "Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"vitest": "^4.1.11"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@hanzo/events": "0.
|
|
78
|
+
"@hanzo/events": "0.3.1"
|
|
79
79
|
},
|
|
80
80
|
"scripts": {
|
|
81
81
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --outDir dist && node ../../scripts/dts.mjs",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Consent: opt-in outside the US, opt-out inside it, GPC always wins.
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { CONSENT_COOKIE, acceptAll, asks, read, region, rejectAll, save } from './consent'
|
|
5
|
+
import { touch, capture } from './touch'
|
|
6
|
+
|
|
7
|
+
const g = globalThis as Record<string, unknown>
|
|
8
|
+
const original = {
|
|
9
|
+
navigator: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
|
10
|
+
Intl: Object.getOwnPropertyDescriptor(globalThis, 'Intl'),
|
|
11
|
+
}
|
|
12
|
+
const put = (k: string, value: unknown) => Object.defineProperty(globalThis, k, { value, configurable: true, writable: true })
|
|
13
|
+
|
|
14
|
+
function browser(zone: string, opts: { gpc?: boolean; cookie?: string; href?: string } = {}) {
|
|
15
|
+
const jar = new Map<string, string>()
|
|
16
|
+
if (opts.cookie !== undefined) jar.set(CONSENT_COOKIE, opts.cookie)
|
|
17
|
+
const fired: string[] = []
|
|
18
|
+
const url = new URL(opts.href ?? 'https://hanzo.ai/')
|
|
19
|
+
g.window = {
|
|
20
|
+
location: { hostname: url.hostname, protocol: url.protocol, search: url.search, href: url.href },
|
|
21
|
+
dispatchEvent: (e: { type: string }) => void fired.push(e.type),
|
|
22
|
+
}
|
|
23
|
+
g.Event = class {
|
|
24
|
+
constructor(public type: string) {}
|
|
25
|
+
}
|
|
26
|
+
put('navigator', { globalPrivacyControl: opts.gpc === true })
|
|
27
|
+
put('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: zone }) }) })
|
|
28
|
+
g.document = {
|
|
29
|
+
get cookie() {
|
|
30
|
+
return [...jar].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ')
|
|
31
|
+
},
|
|
32
|
+
set cookie(line: string) {
|
|
33
|
+
const [pair] = line.split(';')
|
|
34
|
+
const i = pair.indexOf('=')
|
|
35
|
+
const name = pair.slice(0, i)
|
|
36
|
+
const value = decodeURIComponent(pair.slice(i + 1))
|
|
37
|
+
if (/Max-Age=0/.test(line)) jar.delete(name)
|
|
38
|
+
else jar.set(name, value)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
return { jar, fired }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const k of ['window', 'document', 'Event']) delete g[k]
|
|
46
|
+
for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
|
|
47
|
+
vi.restoreAllMocks()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('region', () => {
|
|
51
|
+
it.each([
|
|
52
|
+
['Europe/Berlin', 'opt-in'],
|
|
53
|
+
['Europe/London', 'opt-in'],
|
|
54
|
+
['Europe/Zurich', 'opt-in'],
|
|
55
|
+
['Asia/Tokyo', 'opt-in'],
|
|
56
|
+
['America/New_York', 'opt-out'],
|
|
57
|
+
['America/Los_Angeles', 'opt-out'],
|
|
58
|
+
['America/Indiana/Indianapolis', 'opt-out'],
|
|
59
|
+
['Pacific/Honolulu', 'opt-out'],
|
|
60
|
+
['', 'opt-in'],
|
|
61
|
+
])('%s is %s', (zone, want) => {
|
|
62
|
+
browser(zone)
|
|
63
|
+
expect(region()).toBe(want)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('the default before a choice', () => {
|
|
68
|
+
it('allows nothing in the EU and asks', () => {
|
|
69
|
+
browser('Europe/Paris')
|
|
70
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
71
|
+
expect(asks()).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('allows everything in the US and does not ask', () => {
|
|
75
|
+
browser('America/Chicago')
|
|
76
|
+
expect(read()).toEqual({ analytics: true, marketing: true, ads: true })
|
|
77
|
+
expect(asks()).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('Global Privacy Control turns marketing off in the US', () => {
|
|
81
|
+
browser('America/Denver', { gpc: true })
|
|
82
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('GPC wins over a stored yes', () => {
|
|
86
|
+
browser('America/Denver', { gpc: true, cookie: 'analytics,marketing,ads' })
|
|
87
|
+
expect(read()).toEqual({ analytics: true, marketing: false, ads: false })
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('a stored choice', () => {
|
|
92
|
+
it('is what is read, and stops the asking', () => {
|
|
93
|
+
browser('Europe/Paris', { cookie: 'analytics' })
|
|
94
|
+
expect(read()).toEqual({ analytics: true, marketing: false, ads: false })
|
|
95
|
+
expect(asks()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepting stores all three and tells the page', () => {
|
|
99
|
+
const b = browser('Europe/Paris')
|
|
100
|
+
acceptAll()
|
|
101
|
+
expect(b.jar.get(CONSENT_COOKIE)).toBe('analytics,marketing,ads')
|
|
102
|
+
expect(b.fired).toEqual(['hzconsent'])
|
|
103
|
+
expect(read()).toEqual({ analytics: true, marketing: true, ads: true })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('rejecting stores an empty choice, which is a choice', () => {
|
|
107
|
+
const b = browser('America/New_York')
|
|
108
|
+
rejectAll()
|
|
109
|
+
expect(b.jar.get(CONSENT_COOKIE)).toBe('')
|
|
110
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('saving marketing under GPC stores none', () => {
|
|
114
|
+
const b = browser('Europe/Paris', { gpc: true })
|
|
115
|
+
save({ analytics: true, marketing: true, ads: true })
|
|
116
|
+
expect(b.jar.get(CONSENT_COOKIE)).toBe('analytics')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('click ids', () => {
|
|
121
|
+
const href = (q: string) => `https://hanzo.ai/?${q}`
|
|
122
|
+
|
|
123
|
+
it('keeps every platform click id and builds _fbc from fbclid', () => {
|
|
124
|
+
const b = browser('America/New_York', {
|
|
125
|
+
href: href('gclid=G1&gbraid=GB&wbraid=WB&fbclid=F1&li_fat_id=L1&twclid=T1&ttclid=K1'),
|
|
126
|
+
})
|
|
127
|
+
capture({ analytics: true, marketing: true, ads: true })
|
|
128
|
+
const t = touch({ analytics: true, marketing: true, ads: true })
|
|
129
|
+
expect(t).toMatchObject({ gclid: 'G1', gbraid: 'GB', wbraid: 'WB', fbclid: 'F1', li_fat_id: 'L1', twclid: 'T1', ttclid: 'K1' })
|
|
130
|
+
expect(String(t.fbc)).toMatch(/^fb\.1\.\d+\.F1$/)
|
|
131
|
+
expect(b.jar.get('hz_touch')).toContain('ttclid')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('stores no click without marketing consent', () => {
|
|
135
|
+
const b = browser('Europe/Paris', { href: href('gclid=G1') })
|
|
136
|
+
capture({ analytics: true, marketing: false, ads: false })
|
|
137
|
+
expect(b.jar.has('hz_touch')).toBe(false)
|
|
138
|
+
expect(touch({ analytics: true, marketing: false, ads: false })).not.toHaveProperty('gclid')
|
|
139
|
+
})
|
|
140
|
+
})
|
package/src/consent.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Cookie consent: what a visitor lets a site measure and share, and the rule for
|
|
2
|
+
// what a visitor who has not chosen is presumed to allow.
|
|
3
|
+
//
|
|
4
|
+
// Three categories. Analytics is our own stream and GA4. Marketing is the ad
|
|
5
|
+
// platforms measuring the conversions they bought (Google Ads, Meta, LinkedIn,
|
|
6
|
+
// X, TikTok) and every server-side send. Ads is those platforms using the visit
|
|
7
|
+
// for audiences; off, Google's ad_personalization is denied and Meta runs under
|
|
8
|
+
// Limited Data Use.
|
|
9
|
+
//
|
|
10
|
+
// THE DEFAULT depends on where the visitor is. In the EU, UK and Switzerland,
|
|
11
|
+
// and anywhere else that is not a US state, nothing is allowed until they say
|
|
12
|
+
// so: a visitor with no stored choice has allowed nothing. In the US the default
|
|
13
|
+
// is on and the choice is an opt-out ("Do not sell or share my personal
|
|
14
|
+
// information"). Global Privacy Control, and Do Not Track, turn marketing and
|
|
15
|
+
// ads off in every region whatever is stored: California reads GPC as the opt-out
|
|
16
|
+
// of sale and sharing.
|
|
17
|
+
//
|
|
18
|
+
// There is no geo lookup. The page cannot ask a server where the visitor is, so
|
|
19
|
+
// the zone is the browser's own time zone, and a zone that is not a US zone is
|
|
20
|
+
// treated as opt-in. Wrong in the safe direction: a US visitor on a foreign
|
|
21
|
+
// clock is asked, and a European is never presumed to have agreed.
|
|
22
|
+
//
|
|
23
|
+
// The choice is a first-party cookie, `hz_consent`, on the registrable domain
|
|
24
|
+
// for 13 months: the granted categories, comma separated. cloud reads the same
|
|
25
|
+
// string off each event's `consent` property.
|
|
26
|
+
|
|
27
|
+
import { get, set } from './cookie'
|
|
28
|
+
|
|
29
|
+
export const CONSENT_COOKIE = 'hz_consent'
|
|
30
|
+
|
|
31
|
+
/** Fired on `window` whenever a choice is stored. */
|
|
32
|
+
export const CONSENT_EVENT = 'hzconsent'
|
|
33
|
+
|
|
34
|
+
export interface Choice {
|
|
35
|
+
analytics: boolean
|
|
36
|
+
marketing: boolean
|
|
37
|
+
ads: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Region = 'opt-in' | 'opt-out'
|
|
41
|
+
|
|
42
|
+
type Nav = { doNotTrack?: string; msDoNotTrack?: string; globalPrivacyControl?: boolean }
|
|
43
|
+
|
|
44
|
+
const US =
|
|
45
|
+
/^(America\/(New_York|Chicago|Denver|Los_Angeles|Phoenix|Anchorage|Adak|Boise|Detroit|Juneau|Sitka|Nome|Yakutat|Menominee|Metlakatla|Indiana\/.*|Kentucky\/.*|North_Dakota\/.*)|Pacific\/Honolulu|US\/.*)$/
|
|
46
|
+
|
|
47
|
+
/** Whether the browser sends Global Privacy Control. */
|
|
48
|
+
export function gpc(): boolean {
|
|
49
|
+
if (typeof navigator === 'undefined') return false
|
|
50
|
+
return (navigator as unknown as Nav).globalPrivacyControl === true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether the browser sends Global Privacy Control or Do Not Track. */
|
|
54
|
+
export function refused(): boolean {
|
|
55
|
+
if (typeof window === 'undefined') return false
|
|
56
|
+
const n = navigator as unknown as Nav
|
|
57
|
+
const dnt = n.doNotTrack ?? (window as unknown as Nav).doNotTrack ?? n.msDoNotTrack
|
|
58
|
+
return gpc() || dnt === '1' || dnt === 'yes'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Which rule applies to this visitor. */
|
|
62
|
+
export function region(): Region {
|
|
63
|
+
try {
|
|
64
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''
|
|
65
|
+
return US.test(zone) ? 'opt-out' : 'opt-in'
|
|
66
|
+
} catch {
|
|
67
|
+
return 'opt-in'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the visitor has stored a choice. */
|
|
72
|
+
export function stored(): boolean {
|
|
73
|
+
return get(CONSENT_COOKIE) !== undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The visitor's choice: the stored one, else the regional default, with GPC applied. */
|
|
77
|
+
export function read(): Choice {
|
|
78
|
+
const kept = get(CONSENT_COOKIE)
|
|
79
|
+
const on =
|
|
80
|
+
kept !== undefined
|
|
81
|
+
? kept.split(',')
|
|
82
|
+
: region() === 'opt-in' || refused()
|
|
83
|
+
? []
|
|
84
|
+
: ['analytics', 'marketing', 'ads']
|
|
85
|
+
const has = (k: string) => on.includes(k)
|
|
86
|
+
return { analytics: has('analytics'), marketing: has('marketing') && !gpc(), ads: has('ads') && !gpc() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether the consent banner must be shown: an opt-in visitor who has not chosen. */
|
|
90
|
+
export function asks(): boolean {
|
|
91
|
+
return typeof document !== 'undefined' && region() === 'opt-in' && !stored()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The choice as the `consent` property and the cookie spell it. */
|
|
95
|
+
export function render(c: Choice): string {
|
|
96
|
+
return (['analytics', 'marketing', 'ads'] as const).filter((k) => c[k]).join(',')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stores a choice and tells the page. The tags react without a reload: a
|
|
101
|
+
* category just allowed loads its libraries, and Google's consent state is
|
|
102
|
+
* updated. An ad library already running cannot be unloaded, so a choice that
|
|
103
|
+
* withdraws marketing takes effect for what has not loaded yet and for every
|
|
104
|
+
* later page.
|
|
105
|
+
*/
|
|
106
|
+
export function save(c: Choice): void {
|
|
107
|
+
const out: Choice = { analytics: c.analytics, marketing: c.marketing && !gpc(), ads: c.ads && !gpc() }
|
|
108
|
+
set(CONSENT_COOKIE, render(out), 395)
|
|
109
|
+
if (typeof window !== 'undefined') window.dispatchEvent(new Event(CONSENT_EVENT))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Accept every category. */
|
|
113
|
+
export const acceptAll = (): void => save({ analytics: true, marketing: true, ads: true })
|
|
114
|
+
|
|
115
|
+
/** Refuse every optional category. */
|
|
116
|
+
export const rejectAll = (): void => save({ analytics: false, marketing: false, ads: false })
|
package/src/cookie.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// First-party cookies for consent, the ad click and the browser ids the ad
|
|
2
|
+
// platforms match on. Set on the registrable domain so every subdomain of a site
|
|
3
|
+
// reads the same choice and the same click; on localhost or an address it stays
|
|
4
|
+
// on the host.
|
|
5
|
+
|
|
6
|
+
const DAY = 86_400
|
|
7
|
+
|
|
8
|
+
/** The domain attribute for this host, or '' to keep the cookie on the host. */
|
|
9
|
+
function domain(): string {
|
|
10
|
+
const h = window.location.hostname
|
|
11
|
+
if (h === 'localhost' || /^[\d.]+$/.test(h) || h.includes(':')) return ''
|
|
12
|
+
const parts = h.split('.')
|
|
13
|
+
return parts.length > 2 ? `; Domain=.${parts.slice(-2).join('.')}` : `; Domain=.${h}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reads one cookie, or undefined. */
|
|
17
|
+
export function get(name: string): string | undefined {
|
|
18
|
+
if (typeof document === 'undefined') return undefined
|
|
19
|
+
const hit = document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))
|
|
20
|
+
return hit ? decodeURIComponent(hit.slice(name.length + 1)) : undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Writes one cookie for `days`. */
|
|
24
|
+
export function set(name: string, value: string, days: number): void {
|
|
25
|
+
if (typeof document === 'undefined') return
|
|
26
|
+
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
|
27
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${days * DAY}; Path=/; SameSite=Lax${domain()}${secure}`
|
|
28
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -17,5 +17,5 @@
|
|
|
17
17
|
* of the name, one name per user-visible moment shared by every surface.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
export { EVENTS, PAGEVIEW, EXCEPTION } from '@hanzo/events'
|
|
20
|
+
export { EVENTS, PAGEVIEW, EXCEPTION, PLATFORMS, namesOn } from '@hanzo/events'
|
|
21
21
|
export type { EventName } from '@hanzo/events'
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,14 @@ export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
|
25
25
|
export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
|
|
26
26
|
export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
|
|
27
27
|
export type { EventName } from './events'
|
|
28
|
+
export {
|
|
29
|
+
CONSENT_COOKIE, CONSENT_EVENT, acceptAll, asks, gpc, read as readConsent, refused, region,
|
|
30
|
+
rejectAll, render as renderConsent, save as saveConsent, stored as storedConsent,
|
|
31
|
+
} from './consent'
|
|
32
|
+
export type { Choice, Region } from './consent'
|
|
33
|
+
export { CLICK_IDS, capture as captureClick, touch } from './touch'
|
|
34
|
+
export { start as startTags, track, mirror, reach, visit } from './tags'
|
|
35
|
+
export type { BrowserTag, TagOptions } from './tags'
|
|
28
36
|
export { GOALS, COHORTS } from './goals'
|
|
29
37
|
export type { GoalDef, CohortDef } from './goals'
|
|
30
38
|
export { FUNNELS, PRODUCTS, eventsOf } from './funnels'
|
package/src/tags.test.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The tag manager: nothing loads before consent, everything loads after, and an
|
|
2
|
+
// event reaches each platform under one id and the names the platform table gives.
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const g = globalThis as Record<string, unknown>
|
|
7
|
+
const original = {
|
|
8
|
+
navigator: Object.getOwnPropertyDescriptor(globalThis, 'navigator'),
|
|
9
|
+
Intl: Object.getOwnPropertyDescriptor(globalThis, 'Intl'),
|
|
10
|
+
}
|
|
11
|
+
const put = (k: string, value: unknown) => Object.defineProperty(globalThis, k, { value, configurable: true, writable: true })
|
|
12
|
+
|
|
13
|
+
const TAGS = {
|
|
14
|
+
tags: [
|
|
15
|
+
{ platform: 'ga4', type: 'ga', id: 'G-TEST' },
|
|
16
|
+
{ platform: 'google-ads', type: 'gads', id: 'AW-1', events: { order_completed: 'AW-1/abc' } },
|
|
17
|
+
{ platform: 'meta', type: 'meta', id: '111' },
|
|
18
|
+
{ platform: 'linkedin', type: 'linkedin', id: '555', events: { order_completed: '9876' } },
|
|
19
|
+
{ platform: 'x', type: 'x', id: 'o1abc', events: { order_completed: 'tw-o1abc-buy' } },
|
|
20
|
+
{ platform: 'tiktok', type: 'tiktok', id: 'TT1' },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function browser(zone: string, jar = new Map<string, string>()) {
|
|
25
|
+
const scripts: string[] = []
|
|
26
|
+
const listeners = new Map<string, Array<() => void>>()
|
|
27
|
+
const requests: string[] = []
|
|
28
|
+
put('navigator', { globalPrivacyControl: false, userAgent: 'UA' })
|
|
29
|
+
put('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: zone }) }) })
|
|
30
|
+
g.Event = class {
|
|
31
|
+
constructor(public type: string) {}
|
|
32
|
+
}
|
|
33
|
+
g.window = {
|
|
34
|
+
location: { hostname: 'hanzo.ai', protocol: 'https:', search: '', href: 'https://hanzo.ai/' },
|
|
35
|
+
dispatchEvent: (e: { type: string }) => listeners.get(e.type)?.forEach((f) => f()),
|
|
36
|
+
addEventListener: (t: string, f: () => void) => listeners.set(t, [...(listeners.get(t) ?? []), f]),
|
|
37
|
+
removeEventListener: () => undefined,
|
|
38
|
+
}
|
|
39
|
+
g.addEventListener = (g.window as { addEventListener: unknown }).addEventListener
|
|
40
|
+
g.removeEventListener = () => undefined
|
|
41
|
+
g.document = {
|
|
42
|
+
get cookie() {
|
|
43
|
+
return [...jar].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ')
|
|
44
|
+
},
|
|
45
|
+
set cookie(line: string) {
|
|
46
|
+
const [pair] = line.split(';')
|
|
47
|
+
const i = pair.indexOf('=')
|
|
48
|
+
jar.set(pair.slice(0, i), decodeURIComponent(pair.slice(i + 1)))
|
|
49
|
+
},
|
|
50
|
+
createElement: () => ({}) as Record<string, unknown>,
|
|
51
|
+
head: { appendChild: (s: { src: string; onload?: () => void }) => (scripts.push(s.src), setTimeout(() => s.onload?.(), 0)) },
|
|
52
|
+
}
|
|
53
|
+
put('fetch', async (url: string) => {
|
|
54
|
+
requests.push(url)
|
|
55
|
+
return { ok: true, json: async () => TAGS }
|
|
56
|
+
})
|
|
57
|
+
let n = 0
|
|
58
|
+
put('crypto', { randomUUID: () => `id-${++n}` })
|
|
59
|
+
return { jar, scripts, requests }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => vi.resetModules())
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
for (const k of ['window', 'document', 'Event', 'addEventListener', 'removeEventListener', 'fetch', 'crypto']) delete g[k]
|
|
65
|
+
for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const tick = () => new Promise((r) => setTimeout(r, 10))
|
|
69
|
+
|
|
70
|
+
describe('before consent', () => {
|
|
71
|
+
it('an EU visitor makes no request to any platform', async () => {
|
|
72
|
+
const b = browser('Europe/Berlin')
|
|
73
|
+
const { startTags } = await import('./index')
|
|
74
|
+
startTags({ key: 'pk-x' })
|
|
75
|
+
await tick()
|
|
76
|
+
expect(b.requests).toHaveLength(1)
|
|
77
|
+
expect(b.requests[0]).toContain('/v1/project/tags')
|
|
78
|
+
expect(b.scripts).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('a US visitor is presumed to allow, and every tag loads', async () => {
|
|
82
|
+
const b = browser('America/New_York')
|
|
83
|
+
const { startTags } = await import('./index')
|
|
84
|
+
startTags({ key: 'pk-x' })
|
|
85
|
+
await tick()
|
|
86
|
+
expect(b.scripts.map((s) => new URL(s).hostname).sort()).toEqual(
|
|
87
|
+
['analytics.tiktok.com', 'connect.facebook.net', 'snap.licdn.com', 'static.ads-twitter.com', 'www.googletagmanager.com'].sort(),
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('after accepting', () => {
|
|
93
|
+
it('loads Google, Meta, LinkedIn, X and TikTok with no reload', async () => {
|
|
94
|
+
const b = browser('Europe/Berlin')
|
|
95
|
+
const { startTags, acceptAll } = await import('./index')
|
|
96
|
+
startTags({ key: 'pk-x' })
|
|
97
|
+
await tick()
|
|
98
|
+
acceptAll()
|
|
99
|
+
await tick()
|
|
100
|
+
expect(b.scripts).toHaveLength(5)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('sets Consent Mode v2 denied before any Google script, then updates it', async () => {
|
|
104
|
+
const b = browser('Europe/Berlin')
|
|
105
|
+
const { startTags, acceptAll } = await import('./index')
|
|
106
|
+
startTags({ key: 'pk-x' })
|
|
107
|
+
await tick()
|
|
108
|
+
expect(b.scripts).toEqual([])
|
|
109
|
+
acceptAll()
|
|
110
|
+
await tick()
|
|
111
|
+
const layer = (g.window as { dataLayer: IArguments[] }).dataLayer.map((a) => Array.from(a))
|
|
112
|
+
const dflt = layer.findIndex((a) => a[0] === 'consent' && a[1] === 'default')
|
|
113
|
+
const js = layer.findIndex((a) => a[0] === 'js')
|
|
114
|
+
expect(dflt).toBeGreaterThanOrEqual(0)
|
|
115
|
+
expect(dflt).toBeLessThan(js)
|
|
116
|
+
expect(layer[dflt][2]).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
|
|
117
|
+
const update = layer.find((a) => a[0] === 'consent' && a[1] === 'update')
|
|
118
|
+
expect(update?.[2]).toMatchObject({ analytics_storage: 'granted', ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted' })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('analytics alone loads only Google, cookieless for ads', async () => {
|
|
122
|
+
const b = browser('Europe/Berlin')
|
|
123
|
+
const { startTags, saveConsent } = await import('./index')
|
|
124
|
+
startTags({ key: 'pk-x' })
|
|
125
|
+
await tick()
|
|
126
|
+
saveConsent({ analytics: true, marketing: false, ads: false })
|
|
127
|
+
await tick()
|
|
128
|
+
expect(b.scripts.map((s) => new URL(s).hostname)).toEqual(['www.googletagmanager.com'])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('one event, every tag', () => {
|
|
133
|
+
it('sends purchase under one id to each platform, with the names the table gives', async () => {
|
|
134
|
+
browser('America/New_York')
|
|
135
|
+
const { startTags, track } = await import('./index')
|
|
136
|
+
const captured: Array<{ name: string; props: Record<string, unknown> }> = []
|
|
137
|
+
startTags({ key: 'pk-x' })
|
|
138
|
+
await tick()
|
|
139
|
+
const w = g.window as unknown as Record<string, unknown>
|
|
140
|
+
const gtag: unknown[][] = []
|
|
141
|
+
const fbq: unknown[][] = []
|
|
142
|
+
const ttq: unknown[][] = []
|
|
143
|
+
const twq: unknown[][] = []
|
|
144
|
+
const lintrk: unknown[][] = []
|
|
145
|
+
w.gtag = (...a: unknown[]) => gtag.push(a)
|
|
146
|
+
w.fbq = (...a: unknown[]) => fbq.push(a)
|
|
147
|
+
w.ttq = { track: (...a: unknown[]) => ttq.push(a) }
|
|
148
|
+
w.twq = (...a: unknown[]) => twq.push(a)
|
|
149
|
+
w.lintrk = (...a: unknown[]) => lintrk.push(a)
|
|
150
|
+
const items = [{ item_id: 'dev', item_name: 'Dev', price: 19, quantity: 1 }]
|
|
151
|
+
track({ capture: (name: string, props: Record<string, unknown>) => captured.push({ name, props }) } as never, 'order_completed', {
|
|
152
|
+
order_id: 'ord-1', value: 19, currency: 'USD', items,
|
|
153
|
+
})
|
|
154
|
+
await tick()
|
|
155
|
+
|
|
156
|
+
expect(captured).toHaveLength(1)
|
|
157
|
+
const id = captured[0].props.event_id
|
|
158
|
+
expect(id).toBe('id-1')
|
|
159
|
+
expect(String(captured[0].props.tags).split(',').sort()).toEqual(['ga', 'gads', 'linkedin', 'meta', 'tiktok', 'x'])
|
|
160
|
+
|
|
161
|
+
const ga = gtag.find((a) => a[0] === 'event' && a[1] === 'purchase')
|
|
162
|
+
expect(ga?.[2]).toMatchObject({ transaction_id: 'ord-1', event_id: id, items, value: 19 })
|
|
163
|
+
expect(gtag.find((a) => a[1] === 'conversion')?.[2]).toMatchObject({ send_to: 'AW-1/abc', value: 19 })
|
|
164
|
+
expect(fbq.map((a) => [a[0], a[1], (a[3] as { eventID: string }).eventID])).toEqual([
|
|
165
|
+
['track', 'Purchase', id],
|
|
166
|
+
['track', 'Subscribe', id],
|
|
167
|
+
])
|
|
168
|
+
expect(ttq[0][0]).toBe('CompletePayment')
|
|
169
|
+
expect(ttq[0][2]).toEqual({ event_id: id })
|
|
170
|
+
expect(twq[0][1]).toBe('tw-o1abc-buy')
|
|
171
|
+
expect(lintrk[0]).toEqual(['track', { conversion_id: 9876 }])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('a test order is debug traffic', async () => {
|
|
175
|
+
browser('America/New_York')
|
|
176
|
+
const { startTags, mirror } = await import('./index')
|
|
177
|
+
startTags({ key: 'pk-x' })
|
|
178
|
+
await tick()
|
|
179
|
+
const w = g.window as unknown as Record<string, unknown>
|
|
180
|
+
const gtag: unknown[][] = []
|
|
181
|
+
w.gtag = (...a: unknown[]) => gtag.push(a)
|
|
182
|
+
mirror('order_completed', { test: true, order_id: 'o', value: 19, items: [{ item_id: 'dev', price: 19 }] }, 'id-9', 'ga')
|
|
183
|
+
const sent = gtag.find((a) => a[1] === 'purchase')?.[2] as { value: number; items: Array<{ price: number }> }
|
|
184
|
+
expect(sent).toMatchObject({ debug_mode: true, value: 0 })
|
|
185
|
+
expect(sent.items[0].price).toBe(0)
|
|
186
|
+
})
|
|
187
|
+
})
|