@hanzo/event 0.3.6 → 0.3.9
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 +22 -4
- package/dist/index.cjs +40 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.mjs +39 -23
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +35 -22
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +35 -22
- package/dist/react.mjs.map +1 -1
- package/hz.js +328 -0
- package/package.json +2 -1
- package/src/core.test.ts +102 -0
- package/src/core.ts +35 -10
- package/src/hz.test.ts +96 -0
- package/src/index.ts +1 -0
- package/src/sentry.ts +4 -6
- package/src/storage.ts +11 -9
- package/src/uid.test.ts +73 -0
- package/src/uid.ts +70 -0
- package/src/version.ts +1 -1
package/hz.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
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-host="https://api.hanzo.ai" // optional: API host override
|
|
13
|
+
* data-ga="G-XXXX" data-fb="123" // optional: also fan out to GA4 / Meta
|
|
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
|
+
if (navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes' || window.hzDNT) return
|
|
33
|
+
|
|
34
|
+
var LIB = 'hz.js'
|
|
35
|
+
var VERSION = '0.3.9'
|
|
36
|
+
var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
|
|
37
|
+
var product = s.getAttribute('data-product') || location.hostname
|
|
38
|
+
var capture = s.getAttribute('data-capture') !== '0'
|
|
39
|
+
|
|
40
|
+
// uuidv7 (RFC 9562 §5.7) — the same minter as src/uid.ts, restated here for the
|
|
41
|
+
// same reason `clean` restates scrub.ts: this file has no bundler and cannot
|
|
42
|
+
// import it. It has to be v7. The session rollups on the plane derive a session's
|
|
43
|
+
// start instant from the 48-bit millisecond timestamp the id carries and admit
|
|
44
|
+
// only ids whose version nibble is 7, so a crypto.randomUUID() (v4) session id —
|
|
45
|
+
// and equally the old base36 fallback, which does not even parse as a UUID — is
|
|
46
|
+
// dropped there silently and the session never appears.
|
|
47
|
+
function uid(now) {
|
|
48
|
+
var b = new Uint8Array(16),
|
|
49
|
+
i
|
|
50
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b)
|
|
51
|
+
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
|
|
52
|
+
var t = Math.floor(now || Date.now())
|
|
53
|
+
for (i = 5; i >= 0; i--) {
|
|
54
|
+
b[i] = t % 256
|
|
55
|
+
t = Math.floor(t / 256)
|
|
56
|
+
}
|
|
57
|
+
b[6] = 0x70 | (b[6] & 0x0f) // version 7
|
|
58
|
+
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
|
|
59
|
+
var h = ''
|
|
60
|
+
for (i = 0; i < 16; i++) {
|
|
61
|
+
h += (b[i] + 0x100).toString(16).slice(1)
|
|
62
|
+
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
|
|
63
|
+
}
|
|
64
|
+
return h
|
|
65
|
+
}
|
|
66
|
+
function stored(store, key) {
|
|
67
|
+
try {
|
|
68
|
+
var v = store.getItem(key)
|
|
69
|
+
if (!v) store.setItem(key, (v = uid()))
|
|
70
|
+
return v
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return 'anon'
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
var anon = stored(localStorage, 'hz_id')
|
|
76
|
+
var sid = stored(sessionStorage, 'hz_sid')
|
|
77
|
+
var person = null
|
|
78
|
+
try {
|
|
79
|
+
person = localStorage.getItem('hz_uid')
|
|
80
|
+
} catch (e) {}
|
|
81
|
+
|
|
82
|
+
var queue = [],
|
|
83
|
+
timer
|
|
84
|
+
function flush() {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
if (!queue.length) return
|
|
87
|
+
var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
|
|
88
|
+
var url = host + '/v1/event'
|
|
89
|
+
try {
|
|
90
|
+
if (
|
|
91
|
+
navigator.sendBeacon &&
|
|
92
|
+
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
} catch (e) {}
|
|
96
|
+
fetch(url, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: body,
|
|
99
|
+
keepalive: true,
|
|
100
|
+
headers: { 'content-type': 'application/json' },
|
|
101
|
+
}).catch(function () {})
|
|
102
|
+
}
|
|
103
|
+
// ── location redaction ────────────────────────────────────────────────────
|
|
104
|
+
// The same policy src/scrub.ts applies in the npm client, restated here because
|
|
105
|
+
// this file has no bundler and therefore cannot import it: a reset, invite or
|
|
106
|
+
// magic link carries a JWT in the query and an address in `?email=`, and the
|
|
107
|
+
// location is stamped on EVERY event — so without this, one page load ships the
|
|
108
|
+
// credential to the warehouse and every later click repeats it.
|
|
109
|
+
//
|
|
110
|
+
// Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
|
|
111
|
+
// error scrubbing (PANs, private keys, stack text) has no counterpart here
|
|
112
|
+
// because this distribution has no error plane. Keep the markers identical to
|
|
113
|
+
// scrub.ts — a warehouse row must not reveal which distribution wrote it.
|
|
114
|
+
var SECRETS = [
|
|
115
|
+
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
|
|
116
|
+
/\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
117
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
|
|
118
|
+
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
119
|
+
/\bhk-[A-Za-z0-9]{16,}/g,
|
|
120
|
+
/\bgh[posru]_[A-Za-z0-9]{20,}/g,
|
|
121
|
+
/\bAIza[0-9A-Za-z_-]{20,}/g,
|
|
122
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
123
|
+
// Bounded like scrub.ts's: the unbounded form backtracks quadratically on
|
|
124
|
+
// colon-rich text that never reaches an '@'.
|
|
125
|
+
/[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
|
|
126
|
+
]
|
|
127
|
+
var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
|
|
128
|
+
function clean(u) {
|
|
129
|
+
if (!u) return u
|
|
130
|
+
if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
|
|
131
|
+
for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
|
|
132
|
+
return u.replace(EMAIL, '[email]')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// send builds ONE WireEvent — the same shape core.ts build() produces, so the
|
|
136
|
+
// server cannot tell which distribution emitted it.
|
|
137
|
+
function send(kind, event, props) {
|
|
138
|
+
queue.push({
|
|
139
|
+
messageId: uid(),
|
|
140
|
+
type: kind,
|
|
141
|
+
event: event,
|
|
142
|
+
timestamp: new Date().toISOString(),
|
|
143
|
+
distinctId: person || anon,
|
|
144
|
+
anonymousId: anon,
|
|
145
|
+
personId: person || undefined,
|
|
146
|
+
sessionId: sid,
|
|
147
|
+
product: product,
|
|
148
|
+
url: clean(location.href),
|
|
149
|
+
path: clean(location.pathname),
|
|
150
|
+
referrer: clean(document.referrer) || undefined,
|
|
151
|
+
properties: props || undefined,
|
|
152
|
+
library: LIB,
|
|
153
|
+
libraryVersion: VERSION,
|
|
154
|
+
})
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
timer = setTimeout(flush, 400)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── element locator (the autocapture detail) ──────────────────────────────
|
|
160
|
+
// A compact, stable, PII-light descriptor of the element interacted with, so
|
|
161
|
+
// movements read logically: tag, short text, id, data-*, and an ancestor path.
|
|
162
|
+
function locator(el) {
|
|
163
|
+
if (!el || el === document) return null
|
|
164
|
+
var o = {
|
|
165
|
+
tag: el.tagName ? el.tagName.toLowerCase() : '',
|
|
166
|
+
id: el.id || undefined,
|
|
167
|
+
name:
|
|
168
|
+
(el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
|
|
169
|
+
undefined,
|
|
170
|
+
}
|
|
171
|
+
var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
|
|
172
|
+
if (txt) o.text = txt
|
|
173
|
+
// A link target is a URL like any other — a share/invite href carries the
|
|
174
|
+
// same token shapes the page URL does.
|
|
175
|
+
if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
|
|
176
|
+
if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
|
|
177
|
+
var p = [],
|
|
178
|
+
n = el,
|
|
179
|
+
i = 0
|
|
180
|
+
while (n && n.tagName && i++ < 4) {
|
|
181
|
+
var seg = n.tagName.toLowerCase()
|
|
182
|
+
if (n.id) {
|
|
183
|
+
seg += '#' + n.id
|
|
184
|
+
p.unshift(seg)
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
if (n.className && typeof n.className === 'string')
|
|
188
|
+
seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
|
|
189
|
+
p.unshift(seg)
|
|
190
|
+
n = n.parentElement
|
|
191
|
+
}
|
|
192
|
+
o.sel = p.join('>')
|
|
193
|
+
return o
|
|
194
|
+
}
|
|
195
|
+
function interactive(el) {
|
|
196
|
+
return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── auto pageviews (initial + SPA) ────────────────────────────────────────
|
|
200
|
+
var last = ''
|
|
201
|
+
function page() {
|
|
202
|
+
var k = location.pathname + location.search
|
|
203
|
+
if (k === last) return
|
|
204
|
+
last = k
|
|
205
|
+
send('pageview', '$pageview')
|
|
206
|
+
}
|
|
207
|
+
page()
|
|
208
|
+
;['pushState', 'replaceState'].forEach(function (m) {
|
|
209
|
+
var o = history[m]
|
|
210
|
+
history[m] = function () {
|
|
211
|
+
var r = o.apply(this, arguments)
|
|
212
|
+
page()
|
|
213
|
+
return r
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
addEventListener('popstate', page)
|
|
217
|
+
|
|
218
|
+
// ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
|
|
219
|
+
if (capture) {
|
|
220
|
+
addEventListener(
|
|
221
|
+
'click',
|
|
222
|
+
function (e) {
|
|
223
|
+
var el = interactive(e.target)
|
|
224
|
+
if (!el) return
|
|
225
|
+
var loc = locator(el)
|
|
226
|
+
send('event', '$click', loc)
|
|
227
|
+
if (el.tagName === 'A' && el.host && el.host !== location.host)
|
|
228
|
+
send('event', '$outbound', { url: clean(el.href), el: loc })
|
|
229
|
+
},
|
|
230
|
+
true,
|
|
231
|
+
)
|
|
232
|
+
addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
|
|
233
|
+
var seen = {}
|
|
234
|
+
addEventListener(
|
|
235
|
+
'scroll',
|
|
236
|
+
function () {
|
|
237
|
+
var d = document.documentElement
|
|
238
|
+
var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
|
|
239
|
+
;[25, 50, 75, 100].forEach(function (m) {
|
|
240
|
+
if (pct >= m && !seen[m]) {
|
|
241
|
+
seen[m] = 1
|
|
242
|
+
send('event', '$scroll', { depth: m })
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
},
|
|
246
|
+
{ passive: true },
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── core web vitals (best-effort, no dep) ─────────────────────────────────
|
|
251
|
+
var vitals = {}
|
|
252
|
+
try {
|
|
253
|
+
new PerformanceObserver(function (l) {
|
|
254
|
+
l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
|
|
255
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true })
|
|
256
|
+
new PerformanceObserver(function (l) {
|
|
257
|
+
l.getEntries().forEach(function (x) {
|
|
258
|
+
if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
|
|
259
|
+
})
|
|
260
|
+
}).observe({ type: 'layout-shift', buffered: true })
|
|
261
|
+
} catch (e) {}
|
|
262
|
+
addEventListener('visibilitychange', function () {
|
|
263
|
+
if (document.visibilityState !== 'hidden') return
|
|
264
|
+
if (vitals.lcp != null || vitals.cls != null) send('event', '$vitals', vitals)
|
|
265
|
+
flush()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
|
|
269
|
+
function assign(a, b) {
|
|
270
|
+
if (b) for (var k in b) a[k] = b[k]
|
|
271
|
+
return a
|
|
272
|
+
}
|
|
273
|
+
window.hanzo = {
|
|
274
|
+
track: function (name, props) { send('event', name, props || undefined) },
|
|
275
|
+
identify: function (id, traits) {
|
|
276
|
+
person = id
|
|
277
|
+
try { localStorage.setItem('hz_uid', id) } catch (e) {}
|
|
278
|
+
send('identify', undefined, traits || undefined)
|
|
279
|
+
},
|
|
280
|
+
page: function (props) {
|
|
281
|
+
last = ''
|
|
282
|
+
page()
|
|
283
|
+
if (props) send('event', 'page_props', props)
|
|
284
|
+
},
|
|
285
|
+
flush: flush,
|
|
286
|
+
}
|
|
287
|
+
var ga = s.getAttribute('data-ga'),
|
|
288
|
+
fb = s.getAttribute('data-fb')
|
|
289
|
+
function load(src) {
|
|
290
|
+
var el = document.createElement('script')
|
|
291
|
+
el.async = true
|
|
292
|
+
el.src = src
|
|
293
|
+
document.head.appendChild(el)
|
|
294
|
+
}
|
|
295
|
+
if (ga) {
|
|
296
|
+
load('https://www.googletagmanager.com/gtag/js?id=' + ga)
|
|
297
|
+
window.dataLayer = window.dataLayer || []
|
|
298
|
+
window.gtag = function () { dataLayer.push(arguments) }
|
|
299
|
+
gtag('js', new Date())
|
|
300
|
+
gtag('config', ga)
|
|
301
|
+
var _t = window.hanzo.track
|
|
302
|
+
window.hanzo.track = function (n, p) {
|
|
303
|
+
_t(n, p)
|
|
304
|
+
try { gtag('event', n, p || {}) } catch (e) {}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (fb) {
|
|
308
|
+
!(function (f) {
|
|
309
|
+
if (f.fbq) return
|
|
310
|
+
var n = (f.fbq = function () {
|
|
311
|
+
n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments)
|
|
312
|
+
})
|
|
313
|
+
if (!f._fbq) f._fbq = n
|
|
314
|
+
n.push = n
|
|
315
|
+
n.loaded = !0
|
|
316
|
+
n.version = '2.0'
|
|
317
|
+
n.queue = []
|
|
318
|
+
})(window)
|
|
319
|
+
load('https://connect.facebook.net/en_US/fbevents.js')
|
|
320
|
+
fbq('init', fb)
|
|
321
|
+
fbq('track', 'PageView')
|
|
322
|
+
var _u = window.hanzo.track
|
|
323
|
+
window.hanzo.track = function (n, p) {
|
|
324
|
+
_u(n, p)
|
|
325
|
+
try { fbq('trackCustom', n, p || {}) } catch (e) {}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
})()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "Hanzo Event \u2014 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 \u2014 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/",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"src",
|
|
20
|
+
"hz.js",
|
|
20
21
|
"README.md",
|
|
21
22
|
"TAXONOMY.md"
|
|
22
23
|
],
|
package/src/core.test.ts
CHANGED
|
@@ -181,6 +181,108 @@ describe('Analytics capture', () => {
|
|
|
181
181
|
}
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
// Stamping the location on EVERY event multiplied an exposure that used to
|
|
185
|
+
// cost one row per page load: a reset/invite/magic link carries a JWT in the
|
|
186
|
+
// query and an address in `?email=`, so without scrubbing, every click on that
|
|
187
|
+
// page ships both to the warehouse in cleartext. The error plane has always
|
|
188
|
+
// scrubbed its free text; the location field is free text too.
|
|
189
|
+
describe('location scrubbing', () => {
|
|
190
|
+
const withLocation = (href: string, referrer: string, fn: () => void) => {
|
|
191
|
+
const g = globalThis as Record<string, unknown>
|
|
192
|
+
const hadWindow = 'window' in g
|
|
193
|
+
const hadDocument = 'document' in g
|
|
194
|
+
const u = new URL(href)
|
|
195
|
+
g.window = {
|
|
196
|
+
location: { href, pathname: u.pathname, search: u.search },
|
|
197
|
+
addEventListener: () => {},
|
|
198
|
+
}
|
|
199
|
+
g.document = { referrer, visibilityState: 'visible' }
|
|
200
|
+
try {
|
|
201
|
+
fn()
|
|
202
|
+
} finally {
|
|
203
|
+
if (!hadWindow) delete g.window
|
|
204
|
+
if (!hadDocument) delete g.document
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const SECRET_URL =
|
|
209
|
+
'https://hanzo.ai/invite/accept?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.QWxnSWdub3JlZA&email=cfo@acme.com'
|
|
210
|
+
|
|
211
|
+
it('redacts a secret and PII from the url of every event kind', () => {
|
|
212
|
+
withLocation(SECRET_URL, '', () => {
|
|
213
|
+
const a = mk()
|
|
214
|
+
a.capture('$click')
|
|
215
|
+
a.pageview()
|
|
216
|
+
a.flush()
|
|
217
|
+
|
|
218
|
+
// Both kinds, because pageview() reaches the wire through a different
|
|
219
|
+
// branch than autocapture does.
|
|
220
|
+
for (const e of tx.all) {
|
|
221
|
+
expect(e.url).not.toContain('eyJhbGciOiJIUzI1NiJ9')
|
|
222
|
+
expect(e.url).not.toContain('cfo@acme.com')
|
|
223
|
+
expect(e.url).toContain('[redacted]')
|
|
224
|
+
expect(e.url).toContain('[email]')
|
|
225
|
+
// Scrubbed, not dropped — the page is still attributable, which is the
|
|
226
|
+
// whole reason the field is stamped.
|
|
227
|
+
expect(e.url).toContain('https://hanzo.ai/invite/accept')
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// pageview() used to pass its own `url` through `...extra`, which merges
|
|
233
|
+
// AFTER the field build() reads — so scrubbing only the read would have left
|
|
234
|
+
// the highest-volume event emitting the raw location. The scrub runs on the
|
|
235
|
+
// assembled record precisely so no call site can route around it.
|
|
236
|
+
it('cannot be bypassed by a call site that supplies its own location', () => {
|
|
237
|
+
withLocation(SECRET_URL, '', () => {
|
|
238
|
+
const a = mk()
|
|
239
|
+
a.pageview('/invite/accept?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.QWxnSWdub3JlZA')
|
|
240
|
+
a.flush()
|
|
241
|
+
const view = tx.all.find((e) => e.type === 'pageview')!
|
|
242
|
+
expect(view.path).not.toContain('eyJhbGciOiJIUzI1NiJ9')
|
|
243
|
+
expect(view.path).toContain('[redacted]')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// document.referrer is the previous page's full URL and is stamped on every
|
|
248
|
+
// event, so it leaks the same way the current location does.
|
|
249
|
+
it('redacts the referrer', () => {
|
|
250
|
+
withLocation('https://hanzo.ai/dashboard', SECRET_URL, () => {
|
|
251
|
+
const a = mk()
|
|
252
|
+
a.capture('$click')
|
|
253
|
+
a.flush()
|
|
254
|
+
expect(tx.all[0].referrer).not.toContain('eyJhbGciOiJIUzI1NiJ9')
|
|
255
|
+
expect(tx.all[0].referrer).not.toContain('cfo@acme.com')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// capturePII is an explicit opt-in for END-USER identifiers. It is NOT a
|
|
260
|
+
// mode that ships credentials: there is no configuration under which a
|
|
261
|
+
// secret leaves the browser.
|
|
262
|
+
it('still redacts secrets when capturePII is enabled', () => {
|
|
263
|
+
withLocation(SECRET_URL, '', () => {
|
|
264
|
+
const a = mk({ capturePII: true })
|
|
265
|
+
a.capture('$click')
|
|
266
|
+
a.flush()
|
|
267
|
+
expect(tx.all[0].url).not.toContain('eyJhbGciOiJIUzI1NiJ9')
|
|
268
|
+
expect(tx.all[0].url).toContain('[redacted]')
|
|
269
|
+
expect(tx.all[0].url).toContain('cfo@acme.com')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// A redactor that mangles ordinary URLs would destroy the analytics it
|
|
274
|
+
// exists to protect, so the common case must pass through byte-for-byte.
|
|
275
|
+
it('leaves an ordinary url untouched', () => {
|
|
276
|
+
withLocation('https://hanzo.ai/pricing?plan=pro&utm_source=x', '', () => {
|
|
277
|
+
const a = mk()
|
|
278
|
+
a.capture('$click')
|
|
279
|
+
a.flush()
|
|
280
|
+
expect(tx.all[0].url).toBe('https://hanzo.ai/pricing?plan=pro&utm_source=x')
|
|
281
|
+
expect(tx.all[0].path).toBe('/pricing')
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
184
286
|
it('auto-flushes when the batch size is reached', () => {
|
|
185
287
|
const a = mk({ batchSize: 3 })
|
|
186
288
|
a.capture('a')
|
package/src/core.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from './attribution'
|
|
46
46
|
import { dsnForProduct } from './dsn'
|
|
47
47
|
import { PAGEVIEW } from './events'
|
|
48
|
+
import { scrubText } from './scrub'
|
|
48
49
|
import {
|
|
49
50
|
buildEnvelope,
|
|
50
51
|
buildSentryEvent,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
getCohort,
|
|
61
62
|
mergeCohort,
|
|
62
63
|
} from './storage'
|
|
64
|
+
import { uuidv7 } from './uid'
|
|
63
65
|
import type {
|
|
64
66
|
AnalyticsConfig,
|
|
65
67
|
Attribution,
|
|
@@ -110,12 +112,6 @@ function appendQuery(url: string, key: string, value: string): string {
|
|
|
110
112
|
return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function uid(): string {
|
|
114
|
-
const c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
115
|
-
if (c && 'randomUUID' in c) return c.randomUUID()
|
|
116
|
-
return 'm-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
115
|
/** Normalize anything thrown (Error | string | unknown) into an Exception. */
|
|
120
116
|
/** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
|
|
121
117
|
* the event stream's Exception shape. ONE normalizer serves both planes: a thrown
|
|
@@ -311,9 +307,12 @@ export class Analytics {
|
|
|
311
307
|
|
|
312
308
|
/** pageview records a $pageview for the current (or given) location. */
|
|
313
309
|
pageview(path?: string, properties?: Record<string, unknown>): void {
|
|
314
|
-
|
|
310
|
+
// No `url` here: build() reads window.location.href for every event, in the
|
|
311
|
+
// same tick, so this recomputed it to the identical value. Only `path` is
|
|
312
|
+
// passed, because a route change fires before window.location has caught up
|
|
313
|
+
// and the caller's value has to win.
|
|
315
314
|
const p = path ?? (isBrowser() ? window.location.pathname : undefined)
|
|
316
|
-
this.enqueue('pageview', PAGEVIEW, {
|
|
315
|
+
this.enqueue('pageview', PAGEVIEW, { path: p, properties })
|
|
317
316
|
}
|
|
318
317
|
|
|
319
318
|
/** capture records a named product event with optional properties. Commerce
|
|
@@ -467,8 +466,8 @@ export class Analytics {
|
|
|
467
466
|
|
|
468
467
|
private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
|
|
469
468
|
const anon = anonId()
|
|
470
|
-
|
|
471
|
-
messageId:
|
|
469
|
+
const wire: WireEvent = {
|
|
470
|
+
messageId: uuidv7(),
|
|
472
471
|
type: kind,
|
|
473
472
|
event,
|
|
474
473
|
timestamp: new Date().toISOString(),
|
|
@@ -500,6 +499,32 @@ export class Analytics {
|
|
|
500
499
|
libraryVersion: VERSION,
|
|
501
500
|
...extra,
|
|
502
501
|
}
|
|
502
|
+
|
|
503
|
+
// A location is free text an attacker (or an ordinary product flow) controls,
|
|
504
|
+
// and it is now stamped on EVERY event rather than only pageviews — so the
|
|
505
|
+
// one field that is always a URL gets the same policy the error plane has
|
|
506
|
+
// always applied to error text. A password-reset, invite or magic link puts
|
|
507
|
+
// a JWT in the query and an address in `?email=`; without this, one click on
|
|
508
|
+
// that page ships both to the warehouse in cleartext, and every later click
|
|
509
|
+
// repeats it.
|
|
510
|
+
//
|
|
511
|
+
// AFTER `...extra`, deliberately. A call site can pass its own location —
|
|
512
|
+
// pageview() passes `path`, and until this commit it passed `url` too — and
|
|
513
|
+
// `extra` merges over the fields read above, so scrubbing at the read would
|
|
514
|
+
// have left the highest-volume event emitting a raw location while looking
|
|
515
|
+
// scrubbed. Applied to the ASSEMBLED record, the guarantee holds for every
|
|
516
|
+
// call site, including ones not written yet.
|
|
517
|
+
//
|
|
518
|
+
// `scrubText` is the SAME policy as the error plane (secrets always,
|
|
519
|
+
// PII unless capturePII) rather than a second URL-specific redactor: one
|
|
520
|
+
// definition of "must not leave the browser", already tested, mirroring the
|
|
521
|
+
// server's. Guarded on presence so an absent field stays absent instead of
|
|
522
|
+
// becoming the empty string that `host` derivation reads as a page.
|
|
523
|
+
const capturePII = this.cfg.capturePII ?? false
|
|
524
|
+
if (wire.url) wire.url = scrubText(wire.url, capturePII)
|
|
525
|
+
if (wire.path) wire.path = scrubText(wire.path, capturePII)
|
|
526
|
+
if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
|
|
527
|
+
return wire
|
|
503
528
|
}
|
|
504
529
|
|
|
505
530
|
private schedule(): void {
|
package/src/hz.test.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// hz.js is the no-build distribution — 300 lines of shipped client that no test
|
|
2
|
+
// had ever executed. It restates, by hand, what the bundled client imports, so the
|
|
3
|
+
// two can drift; this runs the real file against a minimal browser stub and reads
|
|
4
|
+
// the batch it actually posts.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, beforeEach } from 'vitest'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
const SRC = readFileSync(fileURLToPath(new URL('../hz.js', import.meta.url)), 'utf8')
|
|
11
|
+
|
|
12
|
+
/** The event plane's session-rollup admission gate, transcribed from its own SQL. */
|
|
13
|
+
const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
|
|
14
|
+
const embeddedMs = (id: string): bigint => BigInt('0x' + id.replace(/-/g, '')) >> 80n
|
|
15
|
+
|
|
16
|
+
interface WireEvent {
|
|
17
|
+
messageId: string
|
|
18
|
+
sessionId: string
|
|
19
|
+
anonymousId: string
|
|
20
|
+
type: string
|
|
21
|
+
event?: string
|
|
22
|
+
library: string
|
|
23
|
+
libraryVersion: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Runs hz.js against a stub browser and returns everything it posted. */
|
|
27
|
+
function runSnippet(): { sent: WireEvent[]; api: { track(n: string): void; flush(): void } } {
|
|
28
|
+
const sent: WireEvent[] = []
|
|
29
|
+
const store = () => {
|
|
30
|
+
const m = new Map<string, string>()
|
|
31
|
+
return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v) }
|
|
32
|
+
}
|
|
33
|
+
const g = globalThis as Record<string, unknown>
|
|
34
|
+
g.location = { href: 'https://x.test/p?a=1', pathname: '/p', search: '?a=1', hostname: 'x.test', host: 'x.test' }
|
|
35
|
+
g.document = {
|
|
36
|
+
currentScript: { getAttribute: (a: string) => (a === 'data-product' ? 'test' : null) },
|
|
37
|
+
referrer: '',
|
|
38
|
+
addEventListener: () => {},
|
|
39
|
+
documentElement: { scrollHeight: 1000 },
|
|
40
|
+
visibilityState: 'visible',
|
|
41
|
+
createElement: () => ({}),
|
|
42
|
+
head: { appendChild: () => {} },
|
|
43
|
+
}
|
|
44
|
+
g.navigator = { doNotTrack: '0' }
|
|
45
|
+
g.localStorage = store()
|
|
46
|
+
g.sessionStorage = store()
|
|
47
|
+
g.history = { pushState: () => {}, replaceState: () => {} }
|
|
48
|
+
g.addEventListener = () => {}
|
|
49
|
+
g.PerformanceObserver = undefined
|
|
50
|
+
g.fetch = (_u: string, init: { body: string }) => {
|
|
51
|
+
sent.push(...(JSON.parse(init.body).batch as WireEvent[]))
|
|
52
|
+
return Promise.resolve()
|
|
53
|
+
}
|
|
54
|
+
g.window = g
|
|
55
|
+
new Function(SRC)()
|
|
56
|
+
return { sent, api: (g.window as { hanzo: { track(n: string): void; flush(): void } }).hanzo }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('hz.js', () => {
|
|
60
|
+
let run: ReturnType<typeof runSnippet>
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
run = runSnippet()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('mints session ids the plane admits', () => {
|
|
66
|
+
run.api.track('checkout_started')
|
|
67
|
+
run.api.flush()
|
|
68
|
+
expect(run.sent.length).toBeGreaterThan(0)
|
|
69
|
+
const before = Date.now()
|
|
70
|
+
for (const ev of run.sent) {
|
|
71
|
+
expect(versionNibble(ev.sessionId)).toBe(7n)
|
|
72
|
+
expect(versionNibble(ev.messageId)).toBe(7n)
|
|
73
|
+
expect(versionNibble(ev.anonymousId)).toBe(7n)
|
|
74
|
+
// The embedded instant is the real mint time, not a constant.
|
|
75
|
+
expect(Number(embeddedMs(ev.sessionId))).toBeGreaterThan(before - 60_000)
|
|
76
|
+
expect(Number(embeddedMs(ev.sessionId))).toBeLessThanOrEqual(Date.now())
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('holds one session id across every event it emits', () => {
|
|
81
|
+
run.api.track('a')
|
|
82
|
+
run.api.track('b')
|
|
83
|
+
run.api.flush()
|
|
84
|
+
const ids = new Set(run.sent.map((e) => e.sessionId))
|
|
85
|
+
expect(ids.size).toBe(1)
|
|
86
|
+
expect(new Set(run.sent.map((e) => e.messageId)).size).toBe(run.sent.length)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('emits the auto pageview on load and stamps the library', () => {
|
|
90
|
+
run.api.flush()
|
|
91
|
+
const pv = run.sent.find((e) => e.type === 'pageview')
|
|
92
|
+
expect(pv).toBeDefined()
|
|
93
|
+
expect(pv!.library).toBe('hz.js')
|
|
94
|
+
expect(pv!.libraryVersion).toMatch(/^\d+\.\d+\.\d+$/)
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
export { Analytics, createAnalytics, VERSION, getCohort, getFirstTouch } from './core'
|
|
11
11
|
export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
|
|
12
|
+
export { uuidv7, uuidv7Time } from './uid'
|
|
12
13
|
export { PRODUCT_DSN, dsnForProduct } from './dsn'
|
|
13
14
|
export type { ErrorIdentity } from './sentry'
|
|
14
15
|
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
package/src/sentry.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// normalizeEvent, computeFingerprint). No upstream (FSL) code is used.
|
|
9
9
|
|
|
10
10
|
import { scrubText } from './scrub'
|
|
11
|
+
import { uuidv7 } from './uid'
|
|
11
12
|
import { VERSION } from './version'
|
|
12
13
|
import type {
|
|
13
14
|
CaptureErrorOptions,
|
|
@@ -28,13 +29,10 @@ const MAX_TAG_LEN = 1024
|
|
|
28
29
|
/** Max tags copied from properties. */
|
|
29
30
|
const MAX_TAGS = 50
|
|
30
31
|
|
|
31
|
-
/** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape.
|
|
32
|
+
/** eventId mints a 32-hex-char id (no dashes) — the Sentry event_id shape. Same
|
|
33
|
+
* minter as everything else, just formatted for Sentry's wire. */
|
|
32
34
|
export function eventId(): string {
|
|
33
|
-
|
|
34
|
-
if (c && 'randomUUID' in c) return c.randomUUID().replace(/-/g, '')
|
|
35
|
-
let s = ''
|
|
36
|
-
for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16)
|
|
37
|
-
return s
|
|
35
|
+
return uuidv7().replace(/-/g, '')
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/** byteLen returns the UTF-8 byte length used for envelope item framing. */
|