@hanzo/event 0.3.6 → 0.3.8
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 +8 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +8 -4
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +8 -4
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +8 -4
- package/dist/react.mjs.map +1 -1
- package/hz.js +307 -0
- package/package.json +2 -1
- package/src/core.test.ts +102 -0
- package/src/core.ts +33 -3
- package/src/version.ts +1 -1
package/hz.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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.8'
|
|
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
|
+
function uid() {
|
|
41
|
+
return crypto.randomUUID
|
|
42
|
+
? crypto.randomUUID()
|
|
43
|
+
: Date.now().toString(36) + '.' + Math.random().toString(36).slice(2)
|
|
44
|
+
}
|
|
45
|
+
function stored(store, key) {
|
|
46
|
+
try {
|
|
47
|
+
var v = store.getItem(key)
|
|
48
|
+
if (!v) store.setItem(key, (v = uid()))
|
|
49
|
+
return v
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return 'anon'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
var anon = stored(localStorage, 'hz_id')
|
|
55
|
+
var sid = stored(sessionStorage, 'hz_sid')
|
|
56
|
+
var person = null
|
|
57
|
+
try {
|
|
58
|
+
person = localStorage.getItem('hz_uid')
|
|
59
|
+
} catch (e) {}
|
|
60
|
+
|
|
61
|
+
var queue = [],
|
|
62
|
+
timer
|
|
63
|
+
function flush() {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
if (!queue.length) return
|
|
66
|
+
var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
|
|
67
|
+
var url = host + '/v1/event'
|
|
68
|
+
try {
|
|
69
|
+
if (
|
|
70
|
+
navigator.sendBeacon &&
|
|
71
|
+
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
} catch (e) {}
|
|
75
|
+
fetch(url, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
body: body,
|
|
78
|
+
keepalive: true,
|
|
79
|
+
headers: { 'content-type': 'application/json' },
|
|
80
|
+
}).catch(function () {})
|
|
81
|
+
}
|
|
82
|
+
// ── location redaction ────────────────────────────────────────────────────
|
|
83
|
+
// The same policy src/scrub.ts applies in the npm client, restated here because
|
|
84
|
+
// this file has no bundler and therefore cannot import it: a reset, invite or
|
|
85
|
+
// magic link carries a JWT in the query and an address in `?email=`, and the
|
|
86
|
+
// location is stamped on EVERY event — so without this, one page load ships the
|
|
87
|
+
// credential to the warehouse and every later click repeats it.
|
|
88
|
+
//
|
|
89
|
+
// Deliberately a SUBSET: the shapes that actually appear in a URL. Free-text
|
|
90
|
+
// error scrubbing (PANs, private keys, stack text) has no counterpart here
|
|
91
|
+
// because this distribution has no error plane. Keep the markers identical to
|
|
92
|
+
// scrub.ts — a warehouse row must not reveal which distribution wrote it.
|
|
93
|
+
var SECRETS = [
|
|
94
|
+
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
|
|
95
|
+
/\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
96
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
|
|
97
|
+
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
98
|
+
/\bhk-[A-Za-z0-9]{16,}/g,
|
|
99
|
+
/\bgh[posru]_[A-Za-z0-9]{20,}/g,
|
|
100
|
+
/\bAIza[0-9A-Za-z_-]{20,}/g,
|
|
101
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
102
|
+
// Bounded like scrub.ts's: the unbounded form backtracks quadratically on
|
|
103
|
+
// colon-rich text that never reaches an '@'.
|
|
104
|
+
/[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
|
|
105
|
+
]
|
|
106
|
+
var EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
|
|
107
|
+
function clean(u) {
|
|
108
|
+
if (!u) return u
|
|
109
|
+
if (u.length > 8192) u = u.slice(0, 8192) + '… [truncated]'
|
|
110
|
+
for (var i = 0; i < SECRETS.length; i++) u = u.replace(SECRETS[i], '[redacted]')
|
|
111
|
+
return u.replace(EMAIL, '[email]')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// send builds ONE WireEvent — the same shape core.ts build() produces, so the
|
|
115
|
+
// server cannot tell which distribution emitted it.
|
|
116
|
+
function send(kind, event, props) {
|
|
117
|
+
queue.push({
|
|
118
|
+
messageId: uid(),
|
|
119
|
+
type: kind,
|
|
120
|
+
event: event,
|
|
121
|
+
timestamp: new Date().toISOString(),
|
|
122
|
+
distinctId: person || anon,
|
|
123
|
+
anonymousId: anon,
|
|
124
|
+
personId: person || undefined,
|
|
125
|
+
sessionId: sid,
|
|
126
|
+
product: product,
|
|
127
|
+
url: clean(location.href),
|
|
128
|
+
path: clean(location.pathname),
|
|
129
|
+
referrer: clean(document.referrer) || undefined,
|
|
130
|
+
properties: props || undefined,
|
|
131
|
+
library: LIB,
|
|
132
|
+
libraryVersion: VERSION,
|
|
133
|
+
})
|
|
134
|
+
clearTimeout(timer)
|
|
135
|
+
timer = setTimeout(flush, 400)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── element locator (the autocapture detail) ──────────────────────────────
|
|
139
|
+
// A compact, stable, PII-light descriptor of the element interacted with, so
|
|
140
|
+
// movements read logically: tag, short text, id, data-*, and an ancestor path.
|
|
141
|
+
function locator(el) {
|
|
142
|
+
if (!el || el === document) return null
|
|
143
|
+
var o = {
|
|
144
|
+
tag: el.tagName ? el.tagName.toLowerCase() : '',
|
|
145
|
+
id: el.id || undefined,
|
|
146
|
+
name:
|
|
147
|
+
(el.getAttribute && (el.getAttribute('name') || el.getAttribute('aria-label'))) ||
|
|
148
|
+
undefined,
|
|
149
|
+
}
|
|
150
|
+
var txt = (el.innerText || el.value || '').trim().replace(/\s+/g, ' ').slice(0, 80)
|
|
151
|
+
if (txt) o.text = txt
|
|
152
|
+
// A link target is a URL like any other — a share/invite href carries the
|
|
153
|
+
// same token shapes the page URL does.
|
|
154
|
+
if (el.getAttribute && el.getAttribute('href')) o.href = clean(el.getAttribute('href'))
|
|
155
|
+
if (el.dataset) for (var k in el.dataset) if (k !== 'hz') (o.data = o.data || {})[k] = el.dataset[k]
|
|
156
|
+
var p = [],
|
|
157
|
+
n = el,
|
|
158
|
+
i = 0
|
|
159
|
+
while (n && n.tagName && i++ < 4) {
|
|
160
|
+
var seg = n.tagName.toLowerCase()
|
|
161
|
+
if (n.id) {
|
|
162
|
+
seg += '#' + n.id
|
|
163
|
+
p.unshift(seg)
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
if (n.className && typeof n.className === 'string')
|
|
167
|
+
seg += '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.')
|
|
168
|
+
p.unshift(seg)
|
|
169
|
+
n = n.parentElement
|
|
170
|
+
}
|
|
171
|
+
o.sel = p.join('>')
|
|
172
|
+
return o
|
|
173
|
+
}
|
|
174
|
+
function interactive(el) {
|
|
175
|
+
return el && el.closest && el.closest('a,button,[role=button],input,select,textarea,[data-hz],[onclick]')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── auto pageviews (initial + SPA) ────────────────────────────────────────
|
|
179
|
+
var last = ''
|
|
180
|
+
function page() {
|
|
181
|
+
var k = location.pathname + location.search
|
|
182
|
+
if (k === last) return
|
|
183
|
+
last = k
|
|
184
|
+
send('pageview', '$pageview')
|
|
185
|
+
}
|
|
186
|
+
page()
|
|
187
|
+
;['pushState', 'replaceState'].forEach(function (m) {
|
|
188
|
+
var o = history[m]
|
|
189
|
+
history[m] = function () {
|
|
190
|
+
var r = o.apply(this, arguments)
|
|
191
|
+
page()
|
|
192
|
+
return r
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
addEventListener('popstate', page)
|
|
196
|
+
|
|
197
|
+
// ── autocapture: clicks, outbound, scroll depth, form submits ─────────────
|
|
198
|
+
if (capture) {
|
|
199
|
+
addEventListener(
|
|
200
|
+
'click',
|
|
201
|
+
function (e) {
|
|
202
|
+
var el = interactive(e.target)
|
|
203
|
+
if (!el) return
|
|
204
|
+
var loc = locator(el)
|
|
205
|
+
send('event', '$click', loc)
|
|
206
|
+
if (el.tagName === 'A' && el.host && el.host !== location.host)
|
|
207
|
+
send('event', '$outbound', { url: clean(el.href), el: loc })
|
|
208
|
+
},
|
|
209
|
+
true,
|
|
210
|
+
)
|
|
211
|
+
addEventListener('submit', function (e) { send('event', '$form', locator(e.target)) }, true)
|
|
212
|
+
var seen = {}
|
|
213
|
+
addEventListener(
|
|
214
|
+
'scroll',
|
|
215
|
+
function () {
|
|
216
|
+
var d = document.documentElement
|
|
217
|
+
var pct = Math.round(((scrollY + innerHeight) / (d.scrollHeight || 1)) * 100)
|
|
218
|
+
;[25, 50, 75, 100].forEach(function (m) {
|
|
219
|
+
if (pct >= m && !seen[m]) {
|
|
220
|
+
seen[m] = 1
|
|
221
|
+
send('event', '$scroll', { depth: m })
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
},
|
|
225
|
+
{ passive: true },
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── core web vitals (best-effort, no dep) ─────────────────────────────────
|
|
230
|
+
var vitals = {}
|
|
231
|
+
try {
|
|
232
|
+
new PerformanceObserver(function (l) {
|
|
233
|
+
l.getEntries().forEach(function (x) { vitals.lcp = Math.round(x.startTime) })
|
|
234
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true })
|
|
235
|
+
new PerformanceObserver(function (l) {
|
|
236
|
+
l.getEntries().forEach(function (x) {
|
|
237
|
+
if (!x.hadRecentInput) vitals.cls = +((vitals.cls || 0) + x.value).toFixed(3)
|
|
238
|
+
})
|
|
239
|
+
}).observe({ type: 'layout-shift', buffered: true })
|
|
240
|
+
} catch (e) {}
|
|
241
|
+
addEventListener('visibilitychange', function () {
|
|
242
|
+
if (document.visibilityState !== 'hidden') return
|
|
243
|
+
if (vitals.lcp != null || vitals.cls != null) send('event', '$vitals', vitals)
|
|
244
|
+
flush()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
|
|
248
|
+
function assign(a, b) {
|
|
249
|
+
if (b) for (var k in b) a[k] = b[k]
|
|
250
|
+
return a
|
|
251
|
+
}
|
|
252
|
+
window.hanzo = {
|
|
253
|
+
track: function (name, props) { send('event', name, props || undefined) },
|
|
254
|
+
identify: function (id, traits) {
|
|
255
|
+
person = id
|
|
256
|
+
try { localStorage.setItem('hz_uid', id) } catch (e) {}
|
|
257
|
+
send('identify', undefined, traits || undefined)
|
|
258
|
+
},
|
|
259
|
+
page: function (props) {
|
|
260
|
+
last = ''
|
|
261
|
+
page()
|
|
262
|
+
if (props) send('event', 'page_props', props)
|
|
263
|
+
},
|
|
264
|
+
flush: flush,
|
|
265
|
+
}
|
|
266
|
+
var ga = s.getAttribute('data-ga'),
|
|
267
|
+
fb = s.getAttribute('data-fb')
|
|
268
|
+
function load(src) {
|
|
269
|
+
var el = document.createElement('script')
|
|
270
|
+
el.async = true
|
|
271
|
+
el.src = src
|
|
272
|
+
document.head.appendChild(el)
|
|
273
|
+
}
|
|
274
|
+
if (ga) {
|
|
275
|
+
load('https://www.googletagmanager.com/gtag/js?id=' + ga)
|
|
276
|
+
window.dataLayer = window.dataLayer || []
|
|
277
|
+
window.gtag = function () { dataLayer.push(arguments) }
|
|
278
|
+
gtag('js', new Date())
|
|
279
|
+
gtag('config', ga)
|
|
280
|
+
var _t = window.hanzo.track
|
|
281
|
+
window.hanzo.track = function (n, p) {
|
|
282
|
+
_t(n, p)
|
|
283
|
+
try { gtag('event', n, p || {}) } catch (e) {}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (fb) {
|
|
287
|
+
!(function (f) {
|
|
288
|
+
if (f.fbq) return
|
|
289
|
+
var n = (f.fbq = function () {
|
|
290
|
+
n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments)
|
|
291
|
+
})
|
|
292
|
+
if (!f._fbq) f._fbq = n
|
|
293
|
+
n.push = n
|
|
294
|
+
n.loaded = !0
|
|
295
|
+
n.version = '2.0'
|
|
296
|
+
n.queue = []
|
|
297
|
+
})(window)
|
|
298
|
+
load('https://connect.facebook.net/en_US/fbevents.js')
|
|
299
|
+
fbq('init', fb)
|
|
300
|
+
fbq('track', 'PageView')
|
|
301
|
+
var _u = window.hanzo.track
|
|
302
|
+
window.hanzo.track = function (n, p) {
|
|
303
|
+
_u(n, p)
|
|
304
|
+
try { fbq('trackCustom', n, p || {}) } catch (e) {}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
})()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
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,
|
|
@@ -311,9 +312,12 @@ export class Analytics {
|
|
|
311
312
|
|
|
312
313
|
/** pageview records a $pageview for the current (or given) location. */
|
|
313
314
|
pageview(path?: string, properties?: Record<string, unknown>): void {
|
|
314
|
-
|
|
315
|
+
// No `url` here: build() reads window.location.href for every event, in the
|
|
316
|
+
// same tick, so this recomputed it to the identical value. Only `path` is
|
|
317
|
+
// passed, because a route change fires before window.location has caught up
|
|
318
|
+
// and the caller's value has to win.
|
|
315
319
|
const p = path ?? (isBrowser() ? window.location.pathname : undefined)
|
|
316
|
-
this.enqueue('pageview', PAGEVIEW, {
|
|
320
|
+
this.enqueue('pageview', PAGEVIEW, { path: p, properties })
|
|
317
321
|
}
|
|
318
322
|
|
|
319
323
|
/** capture records a named product event with optional properties. Commerce
|
|
@@ -467,7 +471,7 @@ export class Analytics {
|
|
|
467
471
|
|
|
468
472
|
private build(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): WireEvent {
|
|
469
473
|
const anon = anonId()
|
|
470
|
-
|
|
474
|
+
const wire: WireEvent = {
|
|
471
475
|
messageId: uid(),
|
|
472
476
|
type: kind,
|
|
473
477
|
event,
|
|
@@ -500,6 +504,32 @@ export class Analytics {
|
|
|
500
504
|
libraryVersion: VERSION,
|
|
501
505
|
...extra,
|
|
502
506
|
}
|
|
507
|
+
|
|
508
|
+
// A location is free text an attacker (or an ordinary product flow) controls,
|
|
509
|
+
// and it is now stamped on EVERY event rather than only pageviews — so the
|
|
510
|
+
// one field that is always a URL gets the same policy the error plane has
|
|
511
|
+
// always applied to error text. A password-reset, invite or magic link puts
|
|
512
|
+
// a JWT in the query and an address in `?email=`; without this, one click on
|
|
513
|
+
// that page ships both to the warehouse in cleartext, and every later click
|
|
514
|
+
// repeats it.
|
|
515
|
+
//
|
|
516
|
+
// AFTER `...extra`, deliberately. A call site can pass its own location —
|
|
517
|
+
// pageview() passes `path`, and until this commit it passed `url` too — and
|
|
518
|
+
// `extra` merges over the fields read above, so scrubbing at the read would
|
|
519
|
+
// have left the highest-volume event emitting a raw location while looking
|
|
520
|
+
// scrubbed. Applied to the ASSEMBLED record, the guarantee holds for every
|
|
521
|
+
// call site, including ones not written yet.
|
|
522
|
+
//
|
|
523
|
+
// `scrubText` is the SAME policy as the error plane (secrets always,
|
|
524
|
+
// PII unless capturePII) rather than a second URL-specific redactor: one
|
|
525
|
+
// definition of "must not leave the browser", already tested, mirroring the
|
|
526
|
+
// server's. Guarded on presence so an absent field stays absent instead of
|
|
527
|
+
// becoming the empty string that `host` derivation reads as a page.
|
|
528
|
+
const capturePII = this.cfg.capturePII ?? false
|
|
529
|
+
if (wire.url) wire.url = scrubText(wire.url, capturePII)
|
|
530
|
+
if (wire.path) wire.path = scrubText(wire.path, capturePII)
|
|
531
|
+
if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
|
|
532
|
+
return wire
|
|
503
533
|
}
|
|
504
534
|
|
|
505
535
|
private schedule(): void {
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// The library version, stamped on every event (`libraryVersion`) and on the
|
|
2
2
|
// Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
|
|
3
3
|
// `core.ts` — core imports sentry, so the reverse would be an import cycle.
|
|
4
|
-
export const VERSION = '0.3.
|
|
4
|
+
export const VERSION = '0.3.8'
|