@hanzo/event 0.3.32 → 0.3.33
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/README.md +17 -19
- package/dist/index.cjs +14 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +14 -14
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +14 -14
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +14 -14
- package/dist/react.mjs.map +1 -1
- package/package.json +1 -2
- package/src/anon.d.ts +1 -1
- package/src/anon.js +18 -18
- package/src/anon.test.ts +17 -38
- package/src/stack.ts +144 -0
- package/src/storage.test.ts +2 -2
- package/src/storage.ts +1 -1
- package/src/uid.ts +1 -1
- package/src/version.ts +1 -1
- package/hz.js +0 -511
- package/src/hz.test.ts +0 -376
package/hz.js
DELETED
|
@@ -1,511 +0,0 @@
|
|
|
1
|
-
/*! hz.js — the no-build distribution of @hanzo/event.
|
|
2
|
-
*
|
|
3
|
-
* A <script> tag for surfaces that have no bundler: a CMS page, a landing page,
|
|
4
|
-
* a docs site. It speaks the SAME wire as the npm client and posts to the SAME
|
|
5
|
-
* front door — one batch of WireEvents to POST {host}/v1/event:
|
|
6
|
-
*
|
|
7
|
-
* { batch: [ { messageId, type, event, timestamp, distinctId, anonymousId,
|
|
8
|
-
* sessionId, product, url, path, referrer, properties }, … ] }
|
|
9
|
-
*
|
|
10
|
-
* <script async src="https://unpkg.com/@hanzo/event/hz.js"
|
|
11
|
-
* data-product="hanzo.ai" // required: which surface this is
|
|
12
|
-
* data-publishable-key="pk-…" // required off api.hanzo.ai's own origin
|
|
13
|
-
* data-host="https://api.hanzo.ai" // optional: API host override
|
|
14
|
-
* data-capture="1"></script> // optional: autocapture off with "0"
|
|
15
|
-
*
|
|
16
|
-
* It adds what a bundled app does not need and a plain page cannot get: DOM
|
|
17
|
-
* AUTOCAPTURE. Clicks on interactive elements (with a compact element locator),
|
|
18
|
-
* outbound links, scroll depth, form submits and core web vitals arrive as
|
|
19
|
-
* `$click` / `$outbound` / `$scroll` / `$form` / `$vitals` events on the one
|
|
20
|
-
* stream. Manual: window.hanzo.track(name, props) · identify(id, traits) · page().
|
|
21
|
-
* Respects DNT. No PII beyond the short element text the locator carries.
|
|
22
|
-
*
|
|
23
|
-
* It used to live in hanzoai/analytics and post a BARE JSON ARRAY of
|
|
24
|
-
* {site, ts, type, path, …} to analytics.hanzo.ai/v1/event — a second protocol
|
|
25
|
-
* behind an identical path spelling, served by a second collector with its own
|
|
26
|
-
* database. Both are deleted. There is one wire, one door and one client home,
|
|
27
|
-
* and this file is that client's script-tag form.
|
|
28
|
-
*/
|
|
29
|
-
;(function () {
|
|
30
|
-
var s = document.currentScript
|
|
31
|
-
if (!s) return
|
|
32
|
-
|
|
33
|
-
// ── consent ───────────────────────────────────────────────────────────────
|
|
34
|
-
// The same three sources the bundled stack honours, restated here for the same
|
|
35
|
-
// reason the uid minter and the scrubber are: a script tag has no bundler and
|
|
36
|
-
// cannot import them. An EXPLICIT stored choice — `hz_consent`, the key a Hanzo
|
|
37
|
-
// consent banner writes — outranks the browser signal in BOTH directions,
|
|
38
|
-
// because that is what "explicit" means. Otherwise Global Privacy Control (the
|
|
39
|
-
// signal CPRA actually obliges a site to obey) and Do Not Track are refusals.
|
|
40
|
-
var choice = null
|
|
41
|
-
try {
|
|
42
|
-
choice = localStorage.getItem('hz_consent')
|
|
43
|
-
} catch (e) {}
|
|
44
|
-
if (choice !== 'granted') {
|
|
45
|
-
if (choice === 'denied' || window.hzDNT) return
|
|
46
|
-
if (navigator.globalPrivacyControl === true) return
|
|
47
|
-
if (
|
|
48
|
-
navigator.doNotTrack === '1' ||
|
|
49
|
-
navigator.doNotTrack === 'yes' ||
|
|
50
|
-
window.doNotTrack === '1' ||
|
|
51
|
-
navigator.msDoNotTrack === '1'
|
|
52
|
-
)
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
var LIB = 'hz.js'
|
|
57
|
-
var VERSION = '0.3.32'
|
|
58
|
-
var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
|
|
59
|
-
var product = s.getAttribute('data-product') || location.hostname
|
|
60
|
-
var capture = s.getAttribute('data-capture') !== '0'
|
|
61
|
-
// The publishable ingest key (pk-…). Write-only and safe in page source: it
|
|
62
|
-
// attributes a write and mints no reading principal.
|
|
63
|
-
//
|
|
64
|
-
// Without it a tag on any origin but the door's own sends an UNATTRIBUTED
|
|
65
|
-
// write, and the door refuses one (401 ingest_key_required) — silently, since
|
|
66
|
-
// nothing here reads the response. Through 0.3.11 this file had no way to
|
|
67
|
-
// present a key at all, so every keyed static surface looked wired, measured
|
|
68
|
-
// fine in the browser, and filed nothing.
|
|
69
|
-
// data-publishable-key is the name; data-ingest-key is the retiring spelling.
|
|
70
|
-
// The value is the surface's own KMS-sourced key, stamped into the tag by the
|
|
71
|
-
// deploy — never a literal baked in here. With neither attribute the tag is
|
|
72
|
-
// keyless and its writes file under $public; the fix is to stamp the key, not
|
|
73
|
-
// to hardcode the org's credential into this file.
|
|
74
|
-
var key = s.getAttribute('data-publishable-key') || s.getAttribute('data-ingest-key') || ''
|
|
75
|
-
|
|
76
|
-
// ── the shared anonymous-identity chain ───────────────────────────────────
|
|
77
|
-
// COPIED VERBATIM from @hanzo/event's src/anon.js, markers and all, for the same
|
|
78
|
-
// reason `clean` restates scrub.ts: this file has no bundler and cannot import
|
|
79
|
-
// anything. It is the ONE chain and the ONE key, so a page carrying this tag and
|
|
80
|
-
// the npm client resolves to one person; src/anon.test.ts fails on a byte of
|
|
81
|
-
// drift between the two copies. EDIT src/anon.js, never this copy, and do not
|
|
82
|
-
// reformat it — the indentation is part of the byte comparison.
|
|
83
|
-
|
|
84
|
-
/* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
|
|
85
|
-
|
|
86
|
-
/** The ONE anonymous-id key, on every surface and in every distribution. */
|
|
87
|
-
var HZ_ANON_KEY = 'hz_anon_id'
|
|
88
|
-
|
|
89
|
-
/** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
|
|
90
|
-
* and the npm client were two different people on one page. It is READ and never
|
|
91
|
-
* written: an id already in the wild is ADOPTED into the shared identity, because
|
|
92
|
-
* minting over one detaches a returning visitor from their own history. */
|
|
93
|
-
var HZ_ANON_LEGACY_KEY = 'hz_id'
|
|
94
|
-
|
|
95
|
-
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
|
|
96
|
-
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
|
|
97
|
-
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
|
|
98
|
-
var HZ_ANON_DOMAIN = 'hanzo.ai'
|
|
99
|
-
|
|
100
|
-
/** Two years, rewritten on every read, so the cookie rolls forward with the
|
|
101
|
-
* visitor instead of expiring two years after first touch. Safari caps a
|
|
102
|
-
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
|
|
103
|
-
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
|
|
104
|
-
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
|
|
105
|
-
|
|
106
|
-
/** Last resort for a browser that refuses cookies AND localStorage: without it
|
|
107
|
-
* every event in a page load would mint an id of its own. */
|
|
108
|
-
var hzAnonMemo
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
|
|
112
|
-
*
|
|
113
|
-
* It has to be v7, and this is the only minter any distribution may use. The
|
|
114
|
-
* session rollups on the event plane derive a session's start instant FROM THE ID
|
|
115
|
-
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
|
|
116
|
-
* is not merely unordered there — it is DISCARDED, silently, and the rollup stays
|
|
117
|
-
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
|
|
118
|
-
*/
|
|
119
|
-
function hzUuidv7(now) {
|
|
120
|
-
var b = new Uint8Array(16)
|
|
121
|
-
var i
|
|
122
|
-
var c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
123
|
-
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
|
|
124
|
-
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
|
|
125
|
-
var t = Math.floor(now === undefined ? Date.now() : now)
|
|
126
|
-
for (i = 5; i >= 0; i--) {
|
|
127
|
-
b[i] = t % 256
|
|
128
|
-
t = Math.floor(t / 256)
|
|
129
|
-
}
|
|
130
|
-
b[6] = 0x70 | (b[6] & 0x0f) // version 7
|
|
131
|
-
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
|
|
132
|
-
var h = ''
|
|
133
|
-
for (i = 0; i < 16; i++) {
|
|
134
|
-
h += (b[i] + 0x100).toString(16).slice(1)
|
|
135
|
-
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
|
|
136
|
-
}
|
|
137
|
-
return h
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** The cookie jar, or null wherever there is no document to read one from. */
|
|
141
|
-
function hzAnonJar() {
|
|
142
|
-
try {
|
|
143
|
-
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
|
|
144
|
-
return document
|
|
145
|
-
} catch (e) {
|
|
146
|
-
return null // sandboxed frame with an opaque origin
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** localStorage, or null when the browser refuses it (Safari private mode). */
|
|
151
|
-
function hzAnonStore() {
|
|
152
|
-
try {
|
|
153
|
-
if (typeof window === 'undefined' || !window.localStorage) return null
|
|
154
|
-
return window.localStorage
|
|
155
|
-
} catch (e) {
|
|
156
|
-
return null
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** One stored value, or '' — a jar can read as well as refuse to. */
|
|
161
|
-
function hzAnonItem(store, name) {
|
|
162
|
-
try {
|
|
163
|
-
return (store && store.getItem(name)) || ''
|
|
164
|
-
} catch (e) {
|
|
165
|
-
return ''
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** The value of cookie `name`, or ''. */
|
|
170
|
-
function hzAnonCookie(name) {
|
|
171
|
-
var d = hzAnonJar()
|
|
172
|
-
if (!d) return ''
|
|
173
|
-
var parts = d.cookie.split(';')
|
|
174
|
-
for (var i = 0; i < parts.length; i++) {
|
|
175
|
-
var eq = parts[i].indexOf('=')
|
|
176
|
-
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
|
|
177
|
-
var v = parts[i].slice(eq + 1).trim()
|
|
178
|
-
if (!v) continue
|
|
179
|
-
try {
|
|
180
|
-
return decodeURIComponent(v)
|
|
181
|
-
} catch (e) {
|
|
182
|
-
return v // not percent-encoded — take it as written
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return ''
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Writes `name` on the registrable domain, for as long as the browser allows. */
|
|
189
|
-
function hzAnonWrite(name, value) {
|
|
190
|
-
var d = hzAnonJar()
|
|
191
|
-
if (!d) return
|
|
192
|
-
var host = ''
|
|
193
|
-
var secure = false
|
|
194
|
-
try {
|
|
195
|
-
if (typeof window !== 'undefined' && window.location) {
|
|
196
|
-
host = window.location.hostname || ''
|
|
197
|
-
// A Secure cookie is refused outright by a non-secure origin, which would
|
|
198
|
-
// strand http://localhost dev on the localStorage path.
|
|
199
|
-
secure = window.location.protocol === 'https:'
|
|
200
|
-
}
|
|
201
|
-
} catch (e) {
|
|
202
|
-
/* location unreachable — write a host-only, non-secure cookie */
|
|
203
|
-
}
|
|
204
|
-
// encodeURIComponent leaves a UUID byte-identical while making any value that is
|
|
205
|
-
// not one unable to forge a `;` and inject an attribute.
|
|
206
|
-
var c = name + '=' + encodeURIComponent(value)
|
|
207
|
-
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
|
|
208
|
-
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
|
|
209
|
-
// would be rejected and the whole cookie dropped, so it stays host-only there.
|
|
210
|
-
// Prefixing both sides with '.' matches the domain itself and its subdomains
|
|
211
|
-
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
|
|
212
|
-
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
|
|
213
|
-
c += '; Domain=' + HZ_ANON_DOMAIN
|
|
214
|
-
}
|
|
215
|
-
if (secure) c += '; Secure'
|
|
216
|
-
try {
|
|
217
|
-
d.cookie = c
|
|
218
|
-
} catch (e) {
|
|
219
|
-
/* cookies refused — localStorage still carries the id */
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
|
|
225
|
-
*
|
|
226
|
-
* Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
|
|
227
|
-
* only a browser holding none of them is given a new one:
|
|
228
|
-
*
|
|
229
|
-
* cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
|
|
230
|
-
*
|
|
231
|
-
* Minting over an id resets a returning visitor and detaches them from their own
|
|
232
|
-
* history, so the order is the migration: the cookie is the shared home, the two
|
|
233
|
-
* localStorage keys are what the three implementations wrote before it existed,
|
|
234
|
-
* and each is read until nothing is left to adopt.
|
|
235
|
-
*
|
|
236
|
-
* localStorage keeps being written, so a rollback finds everyone where it left
|
|
237
|
-
* them, and a browser that refuses cookies still holds one id per origin.
|
|
238
|
-
*/
|
|
239
|
-
function hzAnonId() {
|
|
240
|
-
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
|
|
241
|
-
var s = hzAnonStore()
|
|
242
|
-
var id =
|
|
243
|
-
hzAnonCookie(HZ_ANON_KEY) ||
|
|
244
|
-
hzAnonItem(s, HZ_ANON_KEY) ||
|
|
245
|
-
hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
|
|
246
|
-
hzAnonMemo ||
|
|
247
|
-
hzUuidv7()
|
|
248
|
-
hzAnonMemo = id
|
|
249
|
-
hzAnonWrite(HZ_ANON_KEY, id)
|
|
250
|
-
try {
|
|
251
|
-
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
|
|
252
|
-
} catch (e) {
|
|
253
|
-
/* quota exhausted, or a private-mode jar that reads but refuses writes */
|
|
254
|
-
}
|
|
255
|
-
return id
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
|
|
259
|
-
|
|
260
|
-
function stored(store, key) {
|
|
261
|
-
try {
|
|
262
|
-
var v = store.getItem(key)
|
|
263
|
-
if (!v) store.setItem(key, (v = hzUuidv7()))
|
|
264
|
-
return v
|
|
265
|
-
} catch (e) {
|
|
266
|
-
return 'anon'
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
// This used to mint into a localStorage key of its own, so the tag and
|
|
270
|
-
// @hanzo/event counted one visitor as two people. The chain adopts that old id
|
|
271
|
-
// where it exists rather than orphaning it.
|
|
272
|
-
var anon = hzAnonId()
|
|
273
|
-
var sid = stored(sessionStorage, 'hz_sid')
|
|
274
|
-
var person = null
|
|
275
|
-
try {
|
|
276
|
-
person = localStorage.getItem('hz_uid')
|
|
277
|
-
} catch (e) {}
|
|
278
|
-
|
|
279
|
-
var queue = [],
|
|
280
|
-
timer
|
|
281
|
-
function flush() {
|
|
282
|
-
clearTimeout(timer)
|
|
283
|
-
if (!queue.length) return
|
|
284
|
-
var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
|
|
285
|
-
var url = host + '/v1/event'
|
|
286
|
-
// BOTH transports send a SIMPLE request, and for the same reason. The key is
|
|
287
|
-
// publishable (data-publishable-key, write-only and safe in page source), so
|
|
288
|
-
// it rides the query — the one carrier neither transport needs a header for —
|
|
289
|
-
// and the body is text/plain, the CORS-safelisted type. A simple request is
|
|
290
|
-
// sent whatever origin it is on, because the browser asks permission to READ
|
|
291
|
-
// a cross-origin response, never to send one, and nothing here reads it.
|
|
292
|
-
//
|
|
293
|
-
// That property is what lets this file run on a customer's own page at all.
|
|
294
|
-
// Every send from there is cross-origin; an Authorization header or a JSON
|
|
295
|
-
// content type makes the POST preflighted instead, and an origin that does not
|
|
296
|
-
// pass the preflight loses the batch, which splice has already emptied.
|
|
297
|
-
//
|
|
298
|
-
// The door reads the raw body and dispatches on its first non-space byte, so
|
|
299
|
-
// the type names the CORS class and nothing else.
|
|
300
|
-
var wire = key ? url + '?ingest_key=' + encodeURIComponent(key) : url
|
|
301
|
-
try {
|
|
302
|
-
if (navigator.sendBeacon && navigator.sendBeacon(wire, new Blob([body], { type: 'text/plain' })))
|
|
303
|
-
return
|
|
304
|
-
} catch (e) {}
|
|
305
|
-
fetch(wire, {
|
|
306
|
-
method: 'POST',
|
|
307
|
-
body: body,
|
|
308
|
-
keepalive: true,
|
|
309
|
-
credentials: 'omit',
|
|
310
|
-
headers: { 'content-type': 'text/plain' },
|
|
311
|
-
}).catch(function () {})
|
|
312
|
-
}
|
|
313
|
-
// ── location redaction ────────────────────────────────────────────────────
|
|
314
|
-
// The same policy src/scrub.ts applies in the npm client, restated here because
|
|
315
|
-
// this file has no bundler and therefore cannot import it: a reset, invite or
|
|
316
|
-
// magic link carries a JWT in the query and an address in `?email=`, and the
|
|
317
|
-
// location is stamped on EVERY event — so without this, one page load ships the
|
|
318
|
-
// credential to the warehouse and every later click repeats it.
|
|
319
|
-
//
|
|
320
|
-
// Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
|
|
321
|
-
// error scrubbing (PANs, private keys, stack text) has no counterpart here
|
|
322
|
-
// because this distribution has no error plane. Keep the markers identical to
|
|
323
|
-
// scrub.ts — a warehouse row must not reveal which distribution wrote it.
|
|
324
|
-
var SECRETS = [
|
|
325
|
-
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
|
|
326
|
-
/\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
327
|
-
/\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
|
|
328
|
-
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
329
|
-
/\bhk-[A-Za-z0-9]{16,}/g,
|
|
330
|
-
/\bgh[posru]_[A-Za-z0-9]{20,}/g,
|
|
331
|
-
/\bAIza[0-9A-Za-z_-]{20,}/g,
|
|
332
|
-
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
333
|
-
// Bounded like scrub.ts's: the unbounded form backtracks quadratically on
|
|
334
|
-
// colon-rich text that never reaches an '@'.
|
|
335
|
-
/[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
|
|
336
|
-
]
|
|
337
|
-
var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
|
|
338
|
-
function clean(u) {
|
|
339
|
-
if (!u) return u
|
|
340
|
-
if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
|
|
341
|
-
for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
|
|
342
|
-
return u.replace(EMAIL, '[email]')
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// send builds ONE WireEvent — the same shape core.ts build() produces, so the
|
|
346
|
-
// server cannot tell which distribution emitted it.
|
|
347
|
-
function send(kind, event, props) {
|
|
348
|
-
queue.push({
|
|
349
|
-
messageId: hzUuidv7(),
|
|
350
|
-
type: kind,
|
|
351
|
-
event: event,
|
|
352
|
-
timestamp: new Date().toISOString(),
|
|
353
|
-
distinctId: person || anon,
|
|
354
|
-
anonymousId: anon,
|
|
355
|
-
personId: person || undefined,
|
|
356
|
-
sessionId: sid,
|
|
357
|
-
product: product,
|
|
358
|
-
url: clean(location.href),
|
|
359
|
-
path: clean(location.pathname),
|
|
360
|
-
referrer: clean(document.referrer) || undefined,
|
|
361
|
-
properties: props || undefined,
|
|
362
|
-
library: LIB,
|
|
363
|
-
libraryVersion: VERSION,
|
|
364
|
-
})
|
|
365
|
-
clearTimeout(timer)
|
|
366
|
-
timer = setTimeout(flush, 400)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// ── element locator (the autocapture detail) ──────────────────────────────
|
|
370
|
-
// A compact, stable, PII-light descriptor of the element interacted with, so
|
|
371
|
-
// movements read logically: tag, short text, id, data-*, and an ancestor path.
|
|
372
|
-
function locator(el) {
|
|
373
|
-
if (!el || el === document) return null
|
|
374
|
-
var o = {
|
|
375
|
-
tag: el.tagName ? el.tagName.toLowerCase() : '',
|
|
376
|
-
id: el.id || undefined,
|
|
377
|
-
name:
|
|
378
|
-
(el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
|
|
379
|
-
undefined,
|
|
380
|
-
}
|
|
381
|
-
var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
|
|
382
|
-
if (txt) o.text = txt
|
|
383
|
-
// A link target is a URL like any other — a share/invite href carries the
|
|
384
|
-
// same token shapes the page URL does.
|
|
385
|
-
if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
|
|
386
|
-
if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
|
|
387
|
-
var p = [],
|
|
388
|
-
n = el,
|
|
389
|
-
i = 0
|
|
390
|
-
while (n && n.tagName && i++ < 4) {
|
|
391
|
-
var seg = n.tagName.toLowerCase()
|
|
392
|
-
if (n.id) {
|
|
393
|
-
seg += '#' + n.id
|
|
394
|
-
p.unshift(seg)
|
|
395
|
-
break
|
|
396
|
-
}
|
|
397
|
-
if (n.className && typeof n.className === 'string')
|
|
398
|
-
seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
|
|
399
|
-
p.unshift(seg)
|
|
400
|
-
n = n.parentElement
|
|
401
|
-
}
|
|
402
|
-
o.sel = p.join('>')
|
|
403
|
-
return o
|
|
404
|
-
}
|
|
405
|
-
function interactive(el) {
|
|
406
|
-
return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// ── auto pageviews (initial + SPA) ────────────────────────────────────────
|
|
410
|
-
var last = ''
|
|
411
|
-
function page() {
|
|
412
|
-
var k = location.pathname + location.search
|
|
413
|
-
if (k === last) return
|
|
414
|
-
last = k
|
|
415
|
-
send('pageview', '$pageview')
|
|
416
|
-
}
|
|
417
|
-
page()
|
|
418
|
-
;['pushState', 'replaceState'].forEach(function (m) {
|
|
419
|
-
var o = history[m]
|
|
420
|
-
history[m] = function () {
|
|
421
|
-
var r = o.apply(this, arguments)
|
|
422
|
-
page()
|
|
423
|
-
return r
|
|
424
|
-
}
|
|
425
|
-
})
|
|
426
|
-
addEventListener('popstate', page)
|
|
427
|
-
|
|
428
|
-
// ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
|
|
429
|
-
if (capture) {
|
|
430
|
-
addEventListener(
|
|
431
|
-
'click',
|
|
432
|
-
function (e) {
|
|
433
|
-
var el = interactive(e.target)
|
|
434
|
-
if (!el) return
|
|
435
|
-
var loc = locator(el)
|
|
436
|
-
send('event', '$click', loc)
|
|
437
|
-
if (el.tagName === 'A' && el.host && el.host !== location.host)
|
|
438
|
-
send('event', '$outbound', { url: clean(el.href), el: loc })
|
|
439
|
-
},
|
|
440
|
-
true,
|
|
441
|
-
)
|
|
442
|
-
addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
|
|
443
|
-
var seen = {}
|
|
444
|
-
addEventListener(
|
|
445
|
-
'scroll',
|
|
446
|
-
function () {
|
|
447
|
-
var d = document.documentElement
|
|
448
|
-
var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
|
|
449
|
-
;[25, 50, 75, 100].forEach(function (m) {
|
|
450
|
-
if (pct >= m && !seen[m]) {
|
|
451
|
-
seen[m] = 1
|
|
452
|
-
send('event', '$scroll', { depth: m })
|
|
453
|
-
}
|
|
454
|
-
})
|
|
455
|
-
},
|
|
456
|
-
{ passive: true },
|
|
457
|
-
)
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// ── core web vitals (best-effort, no dep) ─────────────────────────────────
|
|
461
|
-
var vitals = {}
|
|
462
|
-
try {
|
|
463
|
-
new PerformanceObserver(function (l) {
|
|
464
|
-
l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
|
|
465
|
-
}).observe({ type: 'largest-contentful-paint', buffered: true })
|
|
466
|
-
new PerformanceObserver(function (l) {
|
|
467
|
-
l.getEntries().forEach(function (x) {
|
|
468
|
-
if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
|
|
469
|
-
})
|
|
470
|
-
}).observe({ type: 'layout-shift', buffered: true })
|
|
471
|
-
} catch (e) {}
|
|
472
|
-
// A document can be taken away on either signal, and neither one alone covers
|
|
473
|
-
// every browser: visibilitychange is what fires when a tab is backgrounded or
|
|
474
|
-
// discarded, pagehide is what fires on the navigation path where it does not.
|
|
475
|
-
// core.ts listens for both; this listens for both. flush() returns on an empty
|
|
476
|
-
// queue, so whichever arrives second finds nothing left to send.
|
|
477
|
-
var vitalsSent = false
|
|
478
|
-
function leaving() {
|
|
479
|
-
// The web vitals are one measurement of one page view. Hiding a tab twice
|
|
480
|
-
// does not make two of them.
|
|
481
|
-
if (!vitalsSent && (vitals.lcp != null || vitals.cls != null)) {
|
|
482
|
-
vitalsSent = true
|
|
483
|
-
send('event', '$vitals', vitals)
|
|
484
|
-
}
|
|
485
|
-
flush()
|
|
486
|
-
}
|
|
487
|
-
addEventListener('visibilitychange', function () {
|
|
488
|
-
if (document.visibilityState === 'hidden') leaving()
|
|
489
|
-
})
|
|
490
|
-
addEventListener('pagehide', leaving)
|
|
491
|
-
|
|
492
|
-
// ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
|
|
493
|
-
function assign(a, b) {
|
|
494
|
-
if (b) for (var k in b) a[k] = b[k]
|
|
495
|
-
return a
|
|
496
|
-
}
|
|
497
|
-
window.hanzo = {
|
|
498
|
-
track: function (name, props) { send('event', name, props || undefined) },
|
|
499
|
-
identify: function (id, traits) {
|
|
500
|
-
person = id
|
|
501
|
-
try { localStorage.setItem('hz_uid', id) } catch (e) {}
|
|
502
|
-
send('identify', undefined, traits || undefined)
|
|
503
|
-
},
|
|
504
|
-
page: function (props) {
|
|
505
|
-
last = ''
|
|
506
|
-
page()
|
|
507
|
-
if (props) send('event', 'page_props', props)
|
|
508
|
-
},
|
|
509
|
-
flush: flush,
|
|
510
|
-
}
|
|
511
|
-
})()
|