@hanzo/event 0.3.49 → 0.3.52
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 +19 -0
- package/dist/consent.d.ts.map +1 -1
- package/dist/core.d.ts.map +1 -1
- package/dist/index.cjs +90 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +84 -23
- package/dist/index.mjs.map +1 -1
- package/dist/org.d.ts +10 -0
- package/dist/org.d.ts.map +1 -1
- package/dist/react.cjs +48 -16
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.mjs +48 -16
- package/dist/react.mjs.map +1 -1
- package/dist/tags.d.ts +6 -5
- package/dist/tags.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/consent.test.ts +60 -3
- package/src/consent.ts +67 -9
- package/src/core.test.ts +5 -5
- package/src/core.ts +15 -17
- package/src/index.ts +4 -4
- package/src/link.test.ts +4 -3
- package/src/org.test.ts +31 -2
- package/src/org.ts +39 -1
- package/src/react.tsx +6 -2
- package/src/tags.ts +28 -21
package/dist/tags.d.ts
CHANGED
|
@@ -16,12 +16,13 @@ export interface TagOptions {
|
|
|
16
16
|
domains?: string[];
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Resolves once every tag this visitor's choice loads has taken the page view, or after
|
|
20
|
+
* `limit` ms, whichever is first: the tag set has answered, gtag.js has loaded or failed,
|
|
21
|
+
* and Meta's library has replaced the stub that queued the call. A page that is about to
|
|
22
|
+
* leave waits on this so the visit is counted where it arrived. A tag the choice does not
|
|
23
|
+
* load is not waited for.
|
|
23
24
|
*/
|
|
24
|
-
export declare
|
|
25
|
+
export declare function ready(limit?: number): Promise<void>;
|
|
25
26
|
/**
|
|
26
27
|
* Starts the tag manager: fetches the site's tag set and loads what consent
|
|
27
28
|
* allows, again on every consent change. Safe to call on every page load, and
|
package/dist/tags.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"AAqCA,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;AAqCD;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,KAAK,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAcjD;AAoLD;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAE,UAAe,GAAG,MAAM,IAAI,CAoCpD;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;;;;;;;;;;GAUG;AACH,wBAAgB,KAAK,CACnB,MAAM,EAAE,SAAS,GAAG,SAAS,EAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;AACpC,kGAAkG;AAClG,IAAI,CAAC,EAAE,MAAM,GACZ,IAAI,CAsBN;AAED,gGAAgG;AAChG,wBAAgB,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG/C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.52",
|
|
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/",
|
package/src/consent.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Consent: opt-in outside the US, opt-out inside it, GPC always wins.
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { CONSENT_COOKIE, acceptAll, asks, read, region, rejectAll, save } from './consent'
|
|
4
|
+
import { CONSENT_COOKIE, CONSENT_VERSION_COOKIE, acceptAll, asks, notices, read, region, rejectAll, save, serve } from './consent'
|
|
5
5
|
import { touch, capture } from './touch'
|
|
6
6
|
|
|
7
7
|
const g = globalThis as Record<string, unknown>
|
|
@@ -42,6 +42,7 @@ function browser(zone: string, opts: { gpc?: boolean; cookie?: string; href?: st
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
afterEach(() => {
|
|
45
|
+
serve(null)
|
|
45
46
|
for (const k of ['window', 'document', 'Event']) delete g[k]
|
|
46
47
|
for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
|
|
47
48
|
vi.restoreAllMocks()
|
|
@@ -77,9 +78,9 @@ describe('the default before a choice', () => {
|
|
|
77
78
|
expect(asks()).toBe(false)
|
|
78
79
|
})
|
|
79
80
|
|
|
80
|
-
it('Global Privacy Control turns marketing off in the US', () => {
|
|
81
|
+
it('Global Privacy Control turns marketing and ads off in the US, analytics stays', () => {
|
|
81
82
|
browser('America/Denver', { gpc: true })
|
|
82
|
-
expect(read()).toEqual({ analytics:
|
|
83
|
+
expect(read()).toEqual({ analytics: true, marketing: false, ads: false })
|
|
83
84
|
})
|
|
84
85
|
|
|
85
86
|
it('GPC wins over a stored yes', () => {
|
|
@@ -138,3 +139,59 @@ describe('click ids', () => {
|
|
|
138
139
|
expect(touch({ analytics: true, marketing: false, ads: false })).not.toHaveProperty('gclid')
|
|
139
140
|
})
|
|
140
141
|
})
|
|
142
|
+
|
|
143
|
+
describe('the rule cloud serves', () => {
|
|
144
|
+
const rule = (mode: 'opt-in' | 'opt-out', version = 1) => ({
|
|
145
|
+
region: mode === 'opt-in' ? 'DE' : 'US',
|
|
146
|
+
mode,
|
|
147
|
+
version,
|
|
148
|
+
gpc: false,
|
|
149
|
+
defaults:
|
|
150
|
+
mode === 'opt-in'
|
|
151
|
+
? { analytics: false, marketing: false, ads: false }
|
|
152
|
+
: { analytics: true, marketing: true, ads: true },
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('beats the time zone, both ways', () => {
|
|
156
|
+
browser('America/New_York')
|
|
157
|
+
serve(rule('opt-in'))
|
|
158
|
+
expect(region()).toBe('opt-in')
|
|
159
|
+
expect(asks()).toBe(true)
|
|
160
|
+
expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
|
|
161
|
+
browser('Europe/Berlin')
|
|
162
|
+
serve(rule('opt-out'))
|
|
163
|
+
expect(asks()).toBe(false)
|
|
164
|
+
expect(notices()).toBe(true)
|
|
165
|
+
expect(read()).toEqual({ analytics: true, marketing: true, ads: true })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('tells the page when it arrives, so a banner drawn early redraws', () => {
|
|
169
|
+
const b = browser('Europe/Berlin')
|
|
170
|
+
serve(rule('opt-in'))
|
|
171
|
+
expect(b.fired).toContain('hzpolicy')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('a choice made under an older policy is asked again', () => {
|
|
175
|
+
const b = browser('Europe/Berlin', { cookie: 'analytics' })
|
|
176
|
+
b.jar.set(CONSENT_VERSION_COOKIE, '1')
|
|
177
|
+
serve(rule('opt-in', 1))
|
|
178
|
+
expect(asks()).toBe(false)
|
|
179
|
+
serve(rule('opt-in', 2))
|
|
180
|
+
expect(asks()).toBe(true)
|
|
181
|
+
expect(read().analytics).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('a choice is stored with the version it was made under', () => {
|
|
185
|
+
const b = browser('Europe/Berlin')
|
|
186
|
+
serve(rule('opt-in', 3))
|
|
187
|
+
acceptAll()
|
|
188
|
+
expect(b.jar.get(CONSENT_VERSION_COOKIE)).toBe('3')
|
|
189
|
+
expect(asks()).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('ignores an answer that is not a rule', () => {
|
|
193
|
+
browser('Europe/Berlin')
|
|
194
|
+
serve({ tags: [] })
|
|
195
|
+
expect(region()).toBe('opt-in')
|
|
196
|
+
})
|
|
197
|
+
})
|
package/src/consent.ts
CHANGED
|
@@ -15,10 +15,16 @@
|
|
|
15
15
|
// ads off in every region whatever is stored: California reads GPC as the opt-out
|
|
16
16
|
// of sale and sharing.
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
// the
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// The region comes from cloud. The site's tag config (`GET /v1/project/tags`) answers
|
|
19
|
+
// with the rule that binds this visitor, resolved at the edge from where the request
|
|
20
|
+
// came from (`serve`). Until it answers, and if it never does, the zone is the browser's
|
|
21
|
+
// own time zone and a zone that is not a US zone is treated as opt-in: wrong in the
|
|
22
|
+
// safe direction, a US visitor on a foreign clock is asked and a European is never
|
|
23
|
+
// presumed to have agreed.
|
|
24
|
+
//
|
|
25
|
+
// The policy has a version. A choice is stored beside the version it was made under
|
|
26
|
+
// (`hz_consent_v`); when the policy changes, the stored choice no longer counts and the
|
|
27
|
+
// visitor is asked again.
|
|
22
28
|
//
|
|
23
29
|
// The choice is a first-party cookie, `hz_consent`, on the registrable domain
|
|
24
30
|
// for 13 months: the granted categories, comma separated. cloud reads the same
|
|
@@ -28,6 +34,12 @@ import { get, set } from './cookie'
|
|
|
28
34
|
|
|
29
35
|
export const CONSENT_COOKIE = 'hz_consent'
|
|
30
36
|
|
|
37
|
+
/** The policy version the stored choice was made under. */
|
|
38
|
+
export const CONSENT_VERSION_COOKIE = 'hz_consent_v'
|
|
39
|
+
|
|
40
|
+
/** Fired on `window` when cloud's rule for this visitor arrives. */
|
|
41
|
+
export const POLICY_EVENT = 'hzpolicy'
|
|
42
|
+
|
|
31
43
|
/** Fired on `window` whenever a choice is stored. */
|
|
32
44
|
export const CONSENT_EVENT = 'hzconsent'
|
|
33
45
|
|
|
@@ -39,6 +51,40 @@ export interface Choice {
|
|
|
39
51
|
|
|
40
52
|
export type Region = 'opt-in' | 'opt-out'
|
|
41
53
|
|
|
54
|
+
/** The rule cloud resolved for this visitor. */
|
|
55
|
+
export interface Policy {
|
|
56
|
+
region: string
|
|
57
|
+
mode: Region
|
|
58
|
+
defaults: Choice
|
|
59
|
+
gpc: boolean
|
|
60
|
+
version: number
|
|
61
|
+
notice?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let served: Policy | undefined
|
|
65
|
+
|
|
66
|
+
/** Takes cloud's rule for this visitor and tells the page, so a banner drawn early redraws. */
|
|
67
|
+
export function serve(p: unknown): void {
|
|
68
|
+
if (p == null) {
|
|
69
|
+
served = undefined
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const d = p as Partial<Policy>
|
|
73
|
+
if ( (d.mode !== 'opt-in' && d.mode !== 'opt-out') || !d.defaults) return
|
|
74
|
+
served = {
|
|
75
|
+
region: String(d.region ?? ''),
|
|
76
|
+
mode: d.mode,
|
|
77
|
+
defaults: { analytics: !!d.defaults.analytics, marketing: !!d.defaults.marketing, ads: !!d.defaults.ads },
|
|
78
|
+
gpc: d.gpc === true,
|
|
79
|
+
version: Number(d.version) || 0,
|
|
80
|
+
notice: typeof d.notice === 'string' ? d.notice : undefined,
|
|
81
|
+
}
|
|
82
|
+
if (typeof window !== 'undefined') window.dispatchEvent(new Event(POLICY_EVENT))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The rule cloud resolved, once it has answered. */
|
|
86
|
+
export const policy = (): Policy | undefined => served
|
|
87
|
+
|
|
42
88
|
type Nav = { doNotTrack?: string; msDoNotTrack?: string; globalPrivacyControl?: boolean }
|
|
43
89
|
|
|
44
90
|
const US =
|
|
@@ -60,6 +106,7 @@ export function refused(): boolean {
|
|
|
60
106
|
|
|
61
107
|
/** Which rule applies to this visitor. */
|
|
62
108
|
export function region(): Region {
|
|
109
|
+
if (served) return served.mode
|
|
63
110
|
try {
|
|
64
111
|
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''
|
|
65
112
|
return US.test(zone) ? 'opt-out' : 'opt-in'
|
|
@@ -70,18 +117,23 @@ export function region(): Region {
|
|
|
70
117
|
|
|
71
118
|
/** Whether the visitor has stored a choice. */
|
|
72
119
|
export function stored(): boolean {
|
|
73
|
-
|
|
120
|
+
if (get(CONSENT_COOKIE) === undefined) return false
|
|
121
|
+
return !served || get(CONSENT_VERSION_COOKIE) === String(served.version)
|
|
74
122
|
}
|
|
75
123
|
|
|
76
124
|
/** The visitor's choice: the stored one, else the regional default, with GPC applied. */
|
|
77
125
|
export function read(): Choice {
|
|
78
|
-
const kept = get(CONSENT_COOKIE)
|
|
126
|
+
const kept = stored() ? get(CONSENT_COOKIE) : undefined
|
|
79
127
|
const on =
|
|
80
128
|
kept !== undefined
|
|
81
129
|
? kept.split(',')
|
|
82
|
-
:
|
|
83
|
-
? []
|
|
84
|
-
:
|
|
130
|
+
: served
|
|
131
|
+
? (['analytics', 'marketing', 'ads'] as const).filter((k) => served!.defaults[k])
|
|
132
|
+
: region() === 'opt-in'
|
|
133
|
+
? []
|
|
134
|
+
: gpc()
|
|
135
|
+
? ['analytics']
|
|
136
|
+
: ['analytics', 'marketing', 'ads']
|
|
85
137
|
const has = (k: string) => on.includes(k)
|
|
86
138
|
return { analytics: has('analytics'), marketing: has('marketing') && !gpc(), ads: has('ads') && !gpc() }
|
|
87
139
|
}
|
|
@@ -91,6 +143,11 @@ export function asks(): boolean {
|
|
|
91
143
|
return typeof document !== 'undefined' && region() === 'opt-in' && !stored()
|
|
92
144
|
}
|
|
93
145
|
|
|
146
|
+
/** Whether a small notice must be shown: an opt-out visitor who has not yet seen it. */
|
|
147
|
+
export function notices(): boolean {
|
|
148
|
+
return typeof document !== 'undefined' && region() === 'opt-out' && !stored()
|
|
149
|
+
}
|
|
150
|
+
|
|
94
151
|
/** The choice as the `consent` property and the cookie spell it. */
|
|
95
152
|
export function render(c: Choice): string {
|
|
96
153
|
return (['analytics', 'marketing', 'ads'] as const).filter((k) => c[k]).join(',')
|
|
@@ -106,6 +163,7 @@ export function render(c: Choice): string {
|
|
|
106
163
|
export function save(c: Choice): void {
|
|
107
164
|
const out: Choice = { analytics: c.analytics, marketing: c.marketing && !gpc(), ads: c.ads && !gpc() }
|
|
108
165
|
set(CONSENT_COOKIE, render(out), 395)
|
|
166
|
+
set(CONSENT_VERSION_COOKIE, String(served?.version ?? 0), 395)
|
|
109
167
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event(CONSENT_EVENT))
|
|
110
168
|
}
|
|
111
169
|
|
package/src/core.test.ts
CHANGED
|
@@ -396,17 +396,17 @@ describe('Analytics capture', () => {
|
|
|
396
396
|
expect(tx.sent[0].ingestKey).toBeUndefined()
|
|
397
397
|
})
|
|
398
398
|
|
|
399
|
-
it('a signed-in
|
|
400
|
-
// The
|
|
401
|
-
//
|
|
402
|
-
//
|
|
399
|
+
it('a signed-in page sends the key AND the bearer; the bearer never replaces the key', () => {
|
|
400
|
+
// The person is the bearer and the project is the key. The server files the event
|
|
401
|
+
// under the key's project only when it belongs to the bearer's own org, so a key
|
|
402
|
+
// from the build env cannot re-file a person's events under another org.
|
|
403
403
|
process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-live-one-org'
|
|
404
404
|
try {
|
|
405
405
|
const a = mk({ getToken: () => 'jwt-of-a-real-person' })
|
|
406
406
|
a.capture('x')
|
|
407
407
|
a.flush()
|
|
408
408
|
expect(tx.sent[0].token).toBe('jwt-of-a-real-person')
|
|
409
|
-
expect(tx.sent[0].ingestKey).
|
|
409
|
+
expect(tx.sent[0].ingestKey).toBe('pk-live-one-org')
|
|
410
410
|
} finally {
|
|
411
411
|
delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
|
|
412
412
|
}
|
package/src/core.ts
CHANGED
|
@@ -105,6 +105,12 @@ function normalizeError(err: unknown): Exception {
|
|
|
105
105
|
// unload listeners RN doesn't have), while `capture()`/`flush()` — which are NOT
|
|
106
106
|
// gated on this and post through plain `fetch` — still emit. So the client is
|
|
107
107
|
// inert-safe under SSR/Node and live on RN, without a second transport.
|
|
108
|
+
/** The screen and language a page view carries, for the readers that derive device and locale. */
|
|
109
|
+
const viewport = (): Record<string, string> =>
|
|
110
|
+
isBrowser()
|
|
111
|
+
? { screen: `${window.screen?.width ?? 0}x${window.screen?.height ?? 0}`, language: navigator.language || '' }
|
|
112
|
+
: {}
|
|
113
|
+
|
|
108
114
|
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
109
115
|
|
|
110
116
|
/** serializeBatch stringifies a batch, salvaging what it can. `properties` is
|
|
@@ -186,13 +192,15 @@ class DefaultTransport implements Transport {
|
|
|
186
192
|
// for an origin that cannot pass one. A JWT is first-party by construction, so
|
|
187
193
|
// it keeps the header and the session it travels with, and so does the error
|
|
188
194
|
// plane, which names its own envelope type.
|
|
189
|
-
const simple = opts.ingestKey !== undefined && opts.contentType === undefined
|
|
195
|
+
const simple = opts.ingestKey !== undefined && opts.token === undefined && opts.contentType === undefined
|
|
190
196
|
const headers: Record<string, string> = {
|
|
191
197
|
'Content-Type': simple ? BEACON_CONTENT_TYPE : (opts.contentType ?? 'application/json'),
|
|
192
198
|
}
|
|
193
|
-
|
|
199
|
+
// A signed-in page sends the key (which project) in the query AND the person's bearer
|
|
200
|
+
// (who) in the header; the bearer never replaces the key.
|
|
201
|
+
const bearer = simple ? undefined : (opts.token ?? opts.ingestKey)
|
|
194
202
|
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
|
195
|
-
void fetch(
|
|
203
|
+
void fetch(opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url, {
|
|
196
204
|
method: 'POST',
|
|
197
205
|
headers,
|
|
198
206
|
body,
|
|
@@ -375,7 +383,7 @@ export class Analytics {
|
|
|
375
383
|
const view = (p ?? '') + '\0' + (isBrowser() ? window.location.href : '')
|
|
376
384
|
if (view === this.counted) return
|
|
377
385
|
this.counted = view
|
|
378
|
-
this.enqueue('pageview', PAGEVIEW, { path: p, properties })
|
|
386
|
+
this.enqueue('pageview', PAGEVIEW, { path: p, properties: { ...viewport(), ...properties } })
|
|
379
387
|
}
|
|
380
388
|
|
|
381
389
|
/** capture records a named product event with optional properties. Commerce
|
|
@@ -441,20 +449,10 @@ export class Analytics {
|
|
|
441
449
|
this.queue = []
|
|
442
450
|
this.clearTimer()
|
|
443
451
|
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
// a pk- names one org for everybody holding it. So the key is what attributes
|
|
447
|
-
// a visitor nobody has vouched for, and it must never displace someone who
|
|
448
|
-
// has been.
|
|
449
|
-
//
|
|
450
|
-
// The precedence used to run the other way, which was survivable only while
|
|
451
|
-
// the key had to be passed in code. Once it also resolves from the build env,
|
|
452
|
-
// key-wins means setting one variable silently blanks every signed-in user's
|
|
453
|
-
// token and re-files their events under whichever org minted the key — on a
|
|
454
|
-
// console served to several brands from one bundle, that is a cross-tenant
|
|
455
|
-
// leak introduced by an env var.
|
|
452
|
+
// Two kinds of credential, and both ride when both are held: the publishable key
|
|
453
|
+
// names the project, the bearer names the person. The bearer never replaces the key.
|
|
456
454
|
const token = this.cfg.getToken?.() ?? undefined
|
|
457
|
-
const key =
|
|
455
|
+
const key = this.cfg.ingestKey?.trim() || undefined
|
|
458
456
|
// Only a bearer JWT blocks the beacon: sendBeacon cannot set an Authorization
|
|
459
457
|
// header. A pk- rides ?ingest_key; a cookie rides credentials.
|
|
460
458
|
const useBeacon = beacon && !token
|
package/src/index.ts
CHANGED
|
@@ -24,12 +24,12 @@ export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
|
|
|
24
24
|
export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
|
|
25
25
|
export type { EventName } from './events'
|
|
26
26
|
export {
|
|
27
|
-
CONSENT_COOKIE, CONSENT_EVENT, acceptAll, asks, gpc, read as readConsent, refused, region,
|
|
27
|
+
CONSENT_COOKIE, CONSENT_EVENT, CONSENT_VERSION_COOKIE, POLICY_EVENT, acceptAll, asks, gpc, notices, policy as consentPolicy, serve as serveConsent, read as readConsent, refused, region,
|
|
28
28
|
rejectAll, render as renderConsent, save as saveConsent, stored as storedConsent,
|
|
29
29
|
} from './consent'
|
|
30
|
-
export type { Choice, Region } from './consent'
|
|
30
|
+
export type { Choice, Policy as ConsentRule, Region } from './consent'
|
|
31
31
|
export { CLICK_IDS, capture as captureClick, touch } from './touch'
|
|
32
|
-
export { start as startTags, track, mirror, reach, visit
|
|
32
|
+
export { start as startTags, ready as tagsReady, track, mirror, reach, visit } from './tags'
|
|
33
33
|
export type { BrowserTag, TagOptions } from './tags'
|
|
34
34
|
export { commerceItem, worth } from './items'
|
|
35
35
|
export type { CommerceItem, PlanLine } from './items'
|
|
@@ -64,5 +64,5 @@ export type {
|
|
|
64
64
|
* correctly while configuring nothing. `keyFor` takes an optional keyring so a
|
|
65
65
|
* runtime that receives one (hanzo.id serves every brand from one image) resolves
|
|
66
66
|
* through this same function rather than a second copy of it. */
|
|
67
|
-
export { ORG_DOMAIN, ORG_KEY, orgOf, keyFor, keyForPage } from './org'
|
|
67
|
+
export { ORG_DOMAIN, ORG_KEY, SITE_KEY, siteKey, orgOf, keyFor, keyForPage } from './org'
|
|
68
68
|
export type { Keyring } from './org'
|
package/src/link.test.ts
CHANGED
|
@@ -204,11 +204,12 @@ describe('Analytics.link / authorize / identify', () => {
|
|
|
204
204
|
})
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
-
describe('one
|
|
208
|
-
it('hanzo.ai
|
|
207
|
+
describe('one visitor across the journey, each site its own project', () => {
|
|
208
|
+
it('www.hanzo.ai is hanzo.ai; every site owns a key; a runtime keyring stays per org', () => {
|
|
209
209
|
const k = keyFor('hanzo.ai')
|
|
210
210
|
expect(k).toBeTruthy()
|
|
211
|
-
|
|
211
|
+
expect(keyFor('www.hanzo.ai')).toBe(k)
|
|
212
|
+
for (const h of ['hanzo.id', 'hanzo.app', 'hanzo.team', 'hanzo.bot']) expect(keyFor(h)).not.toBe(k)
|
|
212
213
|
const ring = { hanzo: 'pk-runtime' }
|
|
213
214
|
expect(keyFor('hanzo.id', ring)).toBe(keyFor('hanzo.ai', ring))
|
|
214
215
|
})
|
package/src/org.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
2
|
|
|
3
3
|
import { createAnalytics } from './core'
|
|
4
|
-
import { ORG_KEY, keyFor, keyForPage, orgOf } from './org'
|
|
4
|
+
import { ORG_KEY, SITE_KEY, keyFor, keyForPage, orgOf, siteKey } from './org'
|
|
5
5
|
|
|
6
6
|
const ENV = 'NEXT_PUBLIC_PUBLISHABLE_KEY'
|
|
7
7
|
|
|
@@ -45,7 +45,6 @@ describe('the org that owns a host', () => {
|
|
|
45
45
|
'hanzo.codes',
|
|
46
46
|
'hanzoskills.com',
|
|
47
47
|
'hanzo.ventures',
|
|
48
|
-
'hanzo.team',
|
|
49
48
|
]) {
|
|
50
49
|
expect(orgOf(host)).toBe('hanzo')
|
|
51
50
|
expect(keyFor(host)).toBe(ORG_KEY.hanzo)
|
|
@@ -127,3 +126,33 @@ describe('a surface that configures nothing', () => {
|
|
|
127
126
|
expect((none as unknown as { cfg: { ingestKey?: string } }).cfg.ingestKey).toBeUndefined()
|
|
128
127
|
})
|
|
129
128
|
})
|
|
129
|
+
|
|
130
|
+
describe('the hanzo org names its sites', () => {
|
|
131
|
+
it('names hanzo.network, hanzo.market, hanzo.industries and hanzo.agency, each with its own project key', () => {
|
|
132
|
+
for (const host of ['hanzo.network', 'hanzo.market', 'hanzo.industries', 'hanzo.agency']) {
|
|
133
|
+
expect(orgOf(host)).toBe('hanzo')
|
|
134
|
+
expect(keyFor(host)).toBe(SITE_KEY[host])
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('a site files its events under its own project', () => {
|
|
140
|
+
it('each first-party site resolves to its own key, and its tag set follows', () => {
|
|
141
|
+
const keys = Object.values(SITE_KEY)
|
|
142
|
+
expect(keyFor('docs.hanzo.ai')).toBe(SITE_KEY['docs.hanzo.ai'])
|
|
143
|
+
expect(keyFor('hanzo.chat')).toBe(SITE_KEY['hanzo.chat'])
|
|
144
|
+
expect(keyFor('hanzo.team')).not.toBe(keyFor('hanzo.ai'))
|
|
145
|
+
expect(keyFor('hanzo.ai')).toBe(ORG_KEY.hanzo)
|
|
146
|
+
expect(keys.every((k) => k.startsWith('pk-'))).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('www. and a hanzo.app subdomain reach their site', () => {
|
|
150
|
+
expect(siteKey('www.hanzo.ai')).toBe(SITE_KEY['hanzo.ai'])
|
|
151
|
+
expect(siteKey('demo.hanzo.app')).toBe(SITE_KEY['hanzo.app'])
|
|
152
|
+
expect(siteKey('evil-hanzo.ai')).toBeUndefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('a host with no site of its own falls back to its org', () => {
|
|
156
|
+
expect(keyFor('hanzo.works')).toBe(ORG_KEY.hanzo)
|
|
157
|
+
})
|
|
158
|
+
})
|
package/src/org.ts
CHANGED
|
@@ -93,11 +93,45 @@ export const ORG_DOMAIN: Readonly<Record<string, readonly string[]>> = Object.fr
|
|
|
93
93
|
* recreate a project, move BOTH.
|
|
94
94
|
*/
|
|
95
95
|
export const ORG_KEY: Keyring = Object.freeze({
|
|
96
|
-
hanzo: 'pk-
|
|
96
|
+
hanzo: 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
97
97
|
lux: 'pk-gUZp6ZVfhJzSwK-rb4oLbVkpCnMBx5uSCpxf_5yEhQk',
|
|
98
98
|
zoo: 'pk-3TKpKnERV9AQSsBUERWkZejC1O1mUxc1jRzsP3MPbs4',
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The publishable key of each site's project, which is how cloud finds the site's
|
|
103
|
+
* tag set and the project its events are filed under. Each site is ONE project with its
|
|
104
|
+
* own key. A `pk-` is public by design (it ships in every page); it names a site,
|
|
105
|
+
* never a platform id. A host absent here falls back to its org's key for events and
|
|
106
|
+
* has no tag config unless `start` is given a key.
|
|
107
|
+
*/
|
|
108
|
+
export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
|
|
109
|
+
'hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
110
|
+
'www.hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
111
|
+
'hanzo.app': 'pk-wlnXN2a9_vmCm60yTFtQ629Q8TyuaxBNZbY1RWT72gQ',
|
|
112
|
+
'hanzo.team': 'pk-NCzD2FiHpZv8KUpkCX4olT1LJOJsMxBC_Z8NkiQsOFQ',
|
|
113
|
+
'pay.hanzo.ai': 'pk-eX6kv7JZNoiYn1WkeJH3tT_8OvkVYScmCXnLMwxTKf8',
|
|
114
|
+
'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
|
|
115
|
+
'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
|
|
116
|
+
'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
|
|
117
|
+
'hanzo.chat': 'pk-ATxWeB2jNyRSxDgqnkGoDj3CCbzrmTxSktUh9zlrO-E',
|
|
118
|
+
'hanzo.id': 'pk-_GVF3KlaqprlPHDumBMRFeFU1lIGOaarQfgimNVDIo8',
|
|
119
|
+
'hanzo.industries': 'pk-Vh1wvL2HqIAv9qpVvmrznTI9uMHrTD-jyIC-vnlBsgY',
|
|
120
|
+
'hanzo.market': 'pk-DN3xQFa0H61epUVg9OkiOYLzCdteNt9hE6f-S-ednjQ',
|
|
121
|
+
'hanzo.network': 'pk-YJ2eiA_7ckLStMPte8Ar8FsmRp_ycmeIeRbpGprZYmM',
|
|
122
|
+
'hanzo.agency': 'pk-26a2clOaJFe24aKJZ0PkI-GWR9F4EEa8rthHzgy_Efw',
|
|
123
|
+
'console.hanzo.ai': 'pk-oFNXoiO1wOD8PqGdHYyiSq6jaNCdrd7TpztGpwM2ZiQ',
|
|
124
|
+
'admin.hanzo.ai': 'pk-RYZwxkomW-tlAnEu2DKo_kAzcxFbSF2YiYcsUY48QL0',
|
|
125
|
+
'bot.hanzo.ai': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
|
|
126
|
+
'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/** The project key of the site a host is: its own entry, `www.` stripped, a `*.hanzo.app` site under hanzo.app. */
|
|
130
|
+
export function siteKey(host: string): string | undefined {
|
|
131
|
+
const h = normalize(host).replace(/^www\./, '')
|
|
132
|
+
return SITE_KEY[h] ?? (h.endsWith('.hanzo.app') ? SITE_KEY['hanzo.app'] : undefined)
|
|
133
|
+
}
|
|
134
|
+
|
|
101
135
|
/** `pk-` is publishable; `sk-` is not, and there is no third thing. Checked at the
|
|
102
136
|
* one point a key becomes something a page will send, so a mistyped entry fails
|
|
103
137
|
* closed rather than putting a secret in every visitor's tab. */
|
|
@@ -142,6 +176,10 @@ export function orgOf(host: string): string | undefined {
|
|
|
142
176
|
* this SAME function rather than a second copy of it.
|
|
143
177
|
*/
|
|
144
178
|
export function keyFor(host: string, keyring: Keyring = ORG_KEY): string | undefined {
|
|
179
|
+
if (keyring === ORG_KEY) {
|
|
180
|
+
const site = siteKey(host)
|
|
181
|
+
if (site) return site
|
|
182
|
+
}
|
|
145
183
|
const org = orgOf(host)
|
|
146
184
|
if (!org) return undefined
|
|
147
185
|
const key = keyring[org]
|
package/src/react.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from 'react'
|
|
23
23
|
import { Analytics, createAnalytics } from './core'
|
|
24
24
|
import type { AnalyticsConfig } from './types'
|
|
25
|
-
import { CONSENT_EVENT, read, render, type Choice } from './consent'
|
|
25
|
+
import { CONSENT_EVENT, POLICY_EVENT, read, render, type Choice } from './consent'
|
|
26
26
|
|
|
27
27
|
const Ctx = createContext<Analytics | null>(null)
|
|
28
28
|
|
|
@@ -125,7 +125,11 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
125
125
|
|
|
126
126
|
const listen = (on: () => void) => {
|
|
127
127
|
window.addEventListener(CONSENT_EVENT, on)
|
|
128
|
-
|
|
128
|
+
window.addEventListener(POLICY_EVENT, on)
|
|
129
|
+
return () => {
|
|
130
|
+
window.removeEventListener(CONSENT_EVENT, on)
|
|
131
|
+
window.removeEventListener(POLICY_EVENT, on)
|
|
132
|
+
}
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
/**
|
package/src/tags.ts
CHANGED
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
// reads the same table.
|
|
33
33
|
|
|
34
34
|
import { namesOn } from '@hanzo/events'
|
|
35
|
-
import { CONSENT_EVENT, read, render, type Choice } from './consent'
|
|
35
|
+
import { CONSENT_EVENT, read, render, serve, type Choice } from './consent'
|
|
36
|
+
import { siteKey } from './org'
|
|
36
37
|
import { capture, touch } from './touch'
|
|
37
38
|
import type { Analytics } from './core'
|
|
38
39
|
|
|
@@ -54,24 +55,6 @@ export interface TagOptions {
|
|
|
54
55
|
domains?: string[]
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
* The publishable key of each site's project, which is how cloud finds the site's
|
|
59
|
-
* tag set. A `pk-` is public by design (it ships in every page); it names a site,
|
|
60
|
-
* never a platform id. A host absent here has no tag config unless `start` is
|
|
61
|
-
* given a key.
|
|
62
|
-
*/
|
|
63
|
-
export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
|
|
64
|
-
'hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
65
|
-
'www.hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
|
|
66
|
-
'hanzo.app': 'pk-wlnXN2a9_vmCm60yTFtQ629Q8TyuaxBNZbY1RWT72gQ',
|
|
67
|
-
'hanzo.team': 'pk-NCzD2FiHpZv8KUpkCX4olT1LJOJsMxBC_Z8NkiQsOFQ',
|
|
68
|
-
'pay.hanzo.ai': 'pk-eX6kv7JZNoiYn1WkeJH3tT_8OvkVYScmCXnLMwxTKf8',
|
|
69
|
-
'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
|
|
70
|
-
'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
|
|
71
|
-
'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
|
|
72
|
-
'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
|
|
73
|
-
})
|
|
74
|
-
|
|
75
58
|
type Call = (...args: unknown[]) => void
|
|
76
59
|
type Page = {
|
|
77
60
|
dataLayer?: unknown[]
|
|
@@ -107,6 +90,29 @@ function flush(): void {
|
|
|
107
90
|
if (settled() || leaving) held.splice(0).forEach((send) => send())
|
|
108
91
|
}
|
|
109
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Resolves once every tag this visitor's choice loads has taken the page view, or after
|
|
95
|
+
* `limit` ms, whichever is first: the tag set has answered, gtag.js has loaded or failed,
|
|
96
|
+
* and Meta's library has replaced the stub that queued the call. A page that is about to
|
|
97
|
+
* leave waits on this so the visit is counted where it arrived. A tag the choice does not
|
|
98
|
+
* load is not waited for.
|
|
99
|
+
*/
|
|
100
|
+
export function ready(limit = 3000): Promise<void> {
|
|
101
|
+
if (typeof window === 'undefined') return Promise.resolve()
|
|
102
|
+
const start = Date.now()
|
|
103
|
+
const meta = () => {
|
|
104
|
+
const fbq = page().fbq
|
|
105
|
+
return ![...loaded].some((k) => k.startsWith('meta:')) || typeof fbq?.callMethod === 'function'
|
|
106
|
+
}
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const tick = () => {
|
|
109
|
+
if ((configured && settled() && meta()) || Date.now() - start >= limit) return resolve()
|
|
110
|
+
setTimeout(tick, 100)
|
|
111
|
+
}
|
|
112
|
+
tick()
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
110
116
|
function script(src: string, onload?: () => void, onerror?: () => void): void {
|
|
111
117
|
const s = document.createElement('script')
|
|
112
118
|
s.async = true
|
|
@@ -300,7 +306,7 @@ export function start(o: TagOptions = {}): () => void {
|
|
|
300
306
|
const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
|
|
301
307
|
const q = new URLSearchParams()
|
|
302
308
|
const host = o.host ?? window.location.hostname
|
|
303
|
-
const key = o.key ??
|
|
309
|
+
const key = o.key ?? siteKey(host)
|
|
304
310
|
if (key) q.set('key', key)
|
|
305
311
|
q.set('host', host)
|
|
306
312
|
const answer = (list: BrowserTag[]) => {
|
|
@@ -312,8 +318,9 @@ export function start(o: TagOptions = {}): () => void {
|
|
|
312
318
|
const cap = setTimeout(() => answer([]), 3000)
|
|
313
319
|
fetch(`${base}/v1/project/tags?${q}`)
|
|
314
320
|
.then((r) => (r.ok ? r.json() : { tags: [] }))
|
|
315
|
-
.then((j: { tags?: BrowserTag[] }) => {
|
|
321
|
+
.then((j: { tags?: BrowserTag[]; consent?: unknown }) => {
|
|
316
322
|
clearTimeout(cap)
|
|
323
|
+
serve(j.consent)
|
|
317
324
|
answer(Array.isArray(j.tags) ? j.tags : [])
|
|
318
325
|
})
|
|
319
326
|
.catch(() => {
|