@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/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. */
@@ -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"}
@@ -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.42",
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.2.2"
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
- this.attribution = hasAttribution(parsed)
354
- ? setFirstTouchOnce(parsed)
355
- : getFirstTouch() ?? parsed
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'