@hanzo/event 0.3.42 → 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/core.d.ts +8 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/index.cjs +594 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +572 -33
- package/dist/index.mjs.map +1 -1
- package/dist/link.d.ts +28 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/react.cjs +120 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +120 -2
- package/dist/react.mjs.map +1 -1
- package/dist/storage.d.ts +6 -0
- package/dist/storage.d.ts.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/anon.d.ts +3 -0
- package/src/anon.js +19 -1
- package/src/consent.test.ts +140 -0
- package/src/consent.ts +116 -0
- package/src/cookie.ts +28 -0
- package/src/core.ts +45 -3
- package/src/events.ts +1 -1
- package/src/index.ts +10 -0
- package/src/link.test.ts +222 -0
- package/src/link.ts +103 -0
- package/src/storage.ts +14 -1
- package/src/tags.test.ts +187 -0
- package/src/tags.ts +449 -0
- package/src/touch.ts +68 -0
package/dist/storage.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export declare function anonId(): string | undefined;
|
|
|
21
21
|
* and the recorded `last` from disagreeing.
|
|
22
22
|
*/
|
|
23
23
|
export declare function sessionId(now?: number): string | undefined;
|
|
24
|
+
/** Make `id` the current session, as if it had begun now. The session a link
|
|
25
|
+
* carries is the one the visitor is already in, so the journey stays one session
|
|
26
|
+
* across the hop instead of splitting at the host boundary. */
|
|
27
|
+
export declare function adoptSession(id: string, now?: number): void;
|
|
28
|
+
/** Make `id` the browser's anonymous id, replacing the one it held. */
|
|
29
|
+
export declare function adoptAnonId(id: string): void;
|
|
24
30
|
/** Read the persisted first-touch attribution. */
|
|
25
31
|
export declare function getFirstTouch(): Attribution | undefined;
|
|
26
32
|
/** Persist first-touch attribution ONCE — never overwrite an existing record. */
|
package/dist/storage.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAqBlD;;;;;;;;;;;GAWG;AACH,wBAAgB,MAAM,IAAI,MAAM,GAAG,SAAS,CAE3C;AAOD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,SAAa,GAAG,MAAM,GAAG,SAAS,CAgB9D;AAED,kDAAkD;AAClD,wBAAgB,aAAa,IAAI,WAAW,GAAG,SAAS,CASvD;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,WAAW,GAAG,WAAW,CAM7D;AAED,wCAAwC;AACxC,wBAAgB,SAAS,IAAI,MAAM,GAAG,SAAS,CAS9C;AAED,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUjD"}
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAqBlD;;;;;;;;;;;GAWG;AACH,wBAAgB,MAAM,IAAI,MAAM,GAAG,SAAS,CAE3C;AAOD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,SAAa,GAAG,MAAM,GAAG,SAAS,CAgB9D;AAED;;gEAEgE;AAChE,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,IAAI,CAG/D;AAED,uEAAuE;AACvE,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5C;AAED,kDAAkD;AAClD,wBAAgB,aAAa,IAAI,WAAW,GAAG,SAAS,CASvD;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,WAAW,GAAG,WAAW,CAM7D;AAED,wCAAwC;AACxC,wBAAgB,SAAS,IAAI,MAAM,GAAG,SAAS,CAS9C;AAED,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUjD"}
|
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",
|
package/src/anon.d.ts
CHANGED
|
@@ -12,3 +12,6 @@ export declare function hzUuidv7(now?: number): string
|
|
|
12
12
|
* every existing id is adopted, and only a browser holding none is given a new one.
|
|
13
13
|
*/
|
|
14
14
|
export declare function hzAnonId(): string
|
|
15
|
+
|
|
16
|
+
/** Makes `id` this browser's anonymous id, replacing the one it held. */
|
|
17
|
+
export declare function hzAnonAdopt(id: string): void
|
package/src/anon.js
CHANGED
|
@@ -214,6 +214,24 @@ function hzAnonId() {
|
|
|
214
214
|
return id
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* hzAnonAdopt makes `id` this browser's anonymous id, replacing whatever it held.
|
|
219
|
+
*
|
|
220
|
+
* A visitor who followed a link from another Hanzo host arrives carrying the id that
|
|
221
|
+
* host minted; keeping the local one splits one journey into two strangers. The
|
|
222
|
+
* caller has already checked the shape.
|
|
223
|
+
*/
|
|
224
|
+
function hzAnonAdopt(id) {
|
|
225
|
+
hzAnonMemo = id
|
|
226
|
+
hzAnonWrite(HZ_ANON_KEY, id)
|
|
227
|
+
try {
|
|
228
|
+
var s = hzAnonStore()
|
|
229
|
+
if (s) s.setItem(HZ_ANON_KEY, id)
|
|
230
|
+
} catch (e) {
|
|
231
|
+
/* quota exhausted or a read-only jar — the cookie and memo still carry it */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
217
235
|
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
|
|
218
236
|
|
|
219
|
-
export { hzAnonId, hzUuidv7 }
|
|
237
|
+
export { hzAnonId, hzAnonAdopt, hzUuidv7 }
|
|
@@ -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/core.ts
CHANGED
|
@@ -58,6 +58,8 @@ import {
|
|
|
58
58
|
type ErrorIdentity,
|
|
59
59
|
} from './sentry'
|
|
60
60
|
import {
|
|
61
|
+
adoptAnonId,
|
|
62
|
+
adoptSession,
|
|
61
63
|
anonId,
|
|
62
64
|
sessionId,
|
|
63
65
|
getFirstTouch,
|
|
@@ -66,6 +68,7 @@ import {
|
|
|
66
68
|
mergeCohort,
|
|
67
69
|
} from './storage'
|
|
68
70
|
import { uuidv7 } from './uid'
|
|
71
|
+
import { linkUrl, readLink, stripLink } from './link'
|
|
69
72
|
import type {
|
|
70
73
|
AnalyticsConfig,
|
|
71
74
|
Attribution,
|
|
@@ -349,10 +352,26 @@ export class Analytics {
|
|
|
349
352
|
this.started = true
|
|
350
353
|
if (!isBrowser()) return
|
|
351
354
|
|
|
355
|
+
// A link from another Hanzo host carries the visitor with it. Adopt it FIRST, so
|
|
356
|
+
// the first pageview is already the same person, session and first touch.
|
|
357
|
+
const linked = readLink(window.location.search)
|
|
358
|
+
if (linked.anonId) adoptAnonId(linked.anonId)
|
|
359
|
+
if (linked.sessionId) adoptSession(linked.sessionId)
|
|
360
|
+
const clean = stripLink(window.location.href)
|
|
361
|
+
if (clean !== undefined) {
|
|
362
|
+
try {
|
|
363
|
+
window.history.replaceState(window.history.state, '', clean)
|
|
364
|
+
} catch {
|
|
365
|
+
/* a sandboxed frame may refuse; the params are then simply visible */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
352
369
|
const parsed = parseAttribution(window.location.search, document.referrer)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
// The first touch the link carries is the visit's real origin. Without it this
|
|
371
|
+
// host's own "first touch" would be the referring Hanzo page, which says nothing.
|
|
372
|
+
const first = linked.firstTouch && !getFirstTouch() ? setFirstTouchOnce(linked.firstTouch) : undefined
|
|
373
|
+
this.attribution =
|
|
374
|
+
first ?? (hasAttribution(parsed) ? setFirstTouchOnce(parsed) : getFirstTouch() ?? parsed)
|
|
356
375
|
this.cohort = mergeCohort({
|
|
357
376
|
channel: this.attribution.channel ?? deriveChannel(this.attribution),
|
|
358
377
|
refCode: this.attribution.refCode,
|
|
@@ -378,6 +397,29 @@ export class Analytics {
|
|
|
378
397
|
}
|
|
379
398
|
}
|
|
380
399
|
|
|
400
|
+
/** link returns `url` carrying this visitor's anonymous id, session and first
|
|
401
|
+
* touch when the destination is a Hanzo-owned host, so the journey continues
|
|
402
|
+
* across the hop. Any other URL comes back unchanged. */
|
|
403
|
+
link(url: string): string {
|
|
404
|
+
if (!isBrowser()) return url
|
|
405
|
+
if (!this.started) this.init()
|
|
406
|
+
return linkUrl(
|
|
407
|
+
url,
|
|
408
|
+
{ anonId: anonId(), sessionId: sessionId(), firstTouch: this.attribution },
|
|
409
|
+
window.location.hostname,
|
|
410
|
+
window.location.href,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** authorize decorates an OAuth authorize URL for the redirect to IAM and flushes
|
|
415
|
+
* what is queued, because the page is about to unload. Assign the result to
|
|
416
|
+
* `location`. */
|
|
417
|
+
authorize(url: string): string {
|
|
418
|
+
const out = this.link(url)
|
|
419
|
+
this.flush(true)
|
|
420
|
+
return out
|
|
421
|
+
}
|
|
422
|
+
|
|
381
423
|
/** identify binds the current visitor to a stable person id (post-login). */
|
|
382
424
|
identify(personId: string, traits?: Record<string, unknown>): void {
|
|
383
425
|
this.personId = personId
|
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
|
@@ -17,12 +17,22 @@ export {
|
|
|
17
17
|
} from './core'
|
|
18
18
|
export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
|
|
19
19
|
export { uuidv7, uuidv7Time } from './uid'
|
|
20
|
+
export { linkUrl, readLink, stripLink, LINK_PARAMS } from './link'
|
|
21
|
+
export type { LinkState } from './link'
|
|
20
22
|
export { PRODUCT_PROJECT, dsnForProduct } from './dsn'
|
|
21
23
|
export type { ErrorIdentity } from './sentry'
|
|
22
24
|
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
23
25
|
export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
|
|
24
26
|
export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
|
|
25
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'
|
|
26
36
|
export { GOALS, COHORTS } from './goals'
|
|
27
37
|
export type { GoalDef, CohortDef } from './goals'
|
|
28
38
|
export { FUNNELS, PRODUCTS, eventsOf } from './funnels'
|