@hanzo/event 0.3.24 → 0.3.26
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/index.cjs +6 -5
- 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 +6 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +6 -5
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +6 -5
- package/dist/react.mjs.map +1 -1
- package/hz.js +36 -23
- package/package.json +1 -1
- package/src/core.ts +12 -4
- package/src/hz.test.ts +57 -5
- package/src/transport.test.ts +25 -5
- package/src/version.ts +1 -1
package/hz.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
var LIB = 'hz.js'
|
|
58
|
-
var VERSION = '0.3.
|
|
58
|
+
var VERSION = '0.3.26'
|
|
59
59
|
var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
|
|
60
60
|
var product = s.getAttribute('data-product') || location.hostname
|
|
61
61
|
var capture = s.getAttribute('data-capture') !== '0'
|
|
@@ -284,32 +284,31 @@ function hzAnonId() {
|
|
|
284
284
|
if (!queue.length) return
|
|
285
285
|
var body = JSON.stringify({ batch: queue.splice(0, queue.length) })
|
|
286
286
|
var url = host + '/v1/event'
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
287
|
+
// BOTH transports send a SIMPLE request, and for the same reason. The key is
|
|
288
|
+
// publishable (data-publishable-key, write-only and safe in page source), so
|
|
289
|
+
// it rides the query — the one carrier neither transport needs a header for —
|
|
290
|
+
// and the body is text/plain, the CORS-safelisted type. A simple request is
|
|
291
|
+
// sent whatever origin it is on, because the browser asks permission to READ
|
|
292
|
+
// a cross-origin response, never to send one, and nothing here reads it.
|
|
291
293
|
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
294
|
+
// That property is what lets this file run on a customer's own page at all.
|
|
295
|
+
// Every send from there is cross-origin; an Authorization header or a JSON
|
|
296
|
+
// content type makes the POST preflighted instead, and an origin that does not
|
|
297
|
+
// pass the preflight loses the batch, which splice has already emptied.
|
|
298
|
+
//
|
|
299
|
+
// The door reads the raw body and dispatches on its first non-space byte, so
|
|
300
|
+
// the type names the CORS class and nothing else.
|
|
301
|
+
var wire = key ? url + '?ingest_key=' + encodeURIComponent(key) : url
|
|
296
302
|
try {
|
|
297
|
-
if (
|
|
298
|
-
navigator.sendBeacon &&
|
|
299
|
-
navigator.sendBeacon(
|
|
300
|
-
key ? url + '?ingest_key=' + encodeURIComponent(key) : url,
|
|
301
|
-
new Blob([body], { type: 'text/plain' }),
|
|
302
|
-
)
|
|
303
|
-
)
|
|
303
|
+
if (navigator.sendBeacon && navigator.sendBeacon(wire, new Blob([body], { type: 'text/plain' })))
|
|
304
304
|
return
|
|
305
305
|
} catch (e) {}
|
|
306
|
-
|
|
307
|
-
if (key) headers.authorization = 'Bearer ' + key
|
|
308
|
-
fetch(url, {
|
|
306
|
+
fetch(wire, {
|
|
309
307
|
method: 'POST',
|
|
310
308
|
body: body,
|
|
311
309
|
keepalive: true,
|
|
312
|
-
|
|
310
|
+
credentials: 'omit',
|
|
311
|
+
headers: { 'content-type': 'text/plain' },
|
|
313
312
|
}).catch(function () {})
|
|
314
313
|
}
|
|
315
314
|
// ── location redaction ────────────────────────────────────────────────────
|
|
@@ -471,11 +470,25 @@ function hzAnonId() {
|
|
|
471
470
|
})
|
|
472
471
|
}).observe({ type: 'layout-shift', buffered: true })
|
|
473
472
|
} catch (e) {}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
473
|
+
// A document can be taken away on either signal, and neither one alone covers
|
|
474
|
+
// every browser: visibilitychange is what fires when a tab is backgrounded or
|
|
475
|
+
// discarded, pagehide is what fires on the navigation path where it does not.
|
|
476
|
+
// core.ts listens for both; this listens for both. flush() returns on an empty
|
|
477
|
+
// queue, so whichever arrives second finds nothing left to send.
|
|
478
|
+
var vitalsSent = false
|
|
479
|
+
function leaving() {
|
|
480
|
+
// The web vitals are one measurement of one page view. Hiding a tab twice
|
|
481
|
+
// does not make two of them.
|
|
482
|
+
if (!vitalsSent && (vitals.lcp != null || vitals.cls != null)) {
|
|
483
|
+
vitalsSent = true
|
|
484
|
+
send('event', '$vitals', vitals)
|
|
485
|
+
}
|
|
477
486
|
flush()
|
|
487
|
+
}
|
|
488
|
+
addEventListener('visibilitychange', function () {
|
|
489
|
+
if (document.visibilityState === 'hidden') leaving()
|
|
478
490
|
})
|
|
491
|
+
addEventListener('pagehide', leaving)
|
|
479
492
|
|
|
480
493
|
// ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
|
|
481
494
|
function assign(a, b) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.26",
|
|
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/core.ts
CHANGED
|
@@ -207,17 +207,25 @@ class DefaultTransport implements Transport {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
if (typeof fetch !== 'function') return
|
|
210
|
+
// A publishable key makes the send a CORS SIMPLE request: the key rides the
|
|
211
|
+
// query, the body is text/plain, and nothing asks for credentials. That is what
|
|
212
|
+
// lets a bundled client run on a customer's own page — every send from there is
|
|
213
|
+
// cross-origin, and an Authorization header or a JSON type preflights the POST
|
|
214
|
+
// for an origin that cannot pass one. A JWT is first-party by construction, so
|
|
215
|
+
// it keeps the header and the session it travels with, and so does the error
|
|
216
|
+
// plane, which names its own envelope type.
|
|
217
|
+
const simple = opts.ingestKey !== undefined && opts.contentType === undefined
|
|
210
218
|
const headers: Record<string, string> = {
|
|
211
|
-
'Content-Type': opts.contentType ?? 'application/json',
|
|
219
|
+
'Content-Type': simple ? BEACON_CONTENT_TYPE : (opts.contentType ?? 'application/json'),
|
|
212
220
|
}
|
|
213
|
-
const bearer = opts.ingestKey ?? opts.token
|
|
221
|
+
const bearer = simple ? undefined : (opts.ingestKey ?? opts.token)
|
|
214
222
|
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
|
215
|
-
void fetch(url, {
|
|
223
|
+
void fetch(simple ? appendQuery(url, 'ingest_key', opts.ingestKey!) : url, {
|
|
216
224
|
method: 'POST',
|
|
217
225
|
headers,
|
|
218
226
|
body,
|
|
219
227
|
keepalive: true,
|
|
220
|
-
credentials: 'include',
|
|
228
|
+
credentials: simple ? 'omit' : 'include',
|
|
221
229
|
})
|
|
222
230
|
.then((res) => {
|
|
223
231
|
// Telemetry loss never throws into the app — but silence is how this
|
package/src/hz.test.ts
CHANGED
|
@@ -75,8 +75,11 @@ function runSnippet(opts: StubOptions = {}): {
|
|
|
75
75
|
api: Api | undefined
|
|
76
76
|
local: Map<string, string>
|
|
77
77
|
jar: Map<string, string>
|
|
78
|
+
fire: (type: string) => void
|
|
79
|
+
hide: () => void
|
|
78
80
|
} {
|
|
79
81
|
const posts: Post[] = []
|
|
82
|
+
const listeners = new Map<string, (() => void)[]>()
|
|
80
83
|
const local = new Map<string, string>(Object.entries(opts.storage ?? {}))
|
|
81
84
|
const jar = opts.jar ?? new Map<string, string>()
|
|
82
85
|
const store = (m: Map<string, string>) => ({
|
|
@@ -145,7 +148,13 @@ function runSnippet(opts: StubOptions = {}): {
|
|
|
145
148
|
define('localStorage', store(local))
|
|
146
149
|
define('sessionStorage', store(new Map()))
|
|
147
150
|
define('history', { pushState: () => {}, replaceState: () => {} })
|
|
148
|
-
|
|
151
|
+
// Listeners are kept, not discarded: the unload flush IS a listener, so a stub
|
|
152
|
+
// that drops them leaves the only path the drop-off signal travels unexecuted.
|
|
153
|
+
define('addEventListener', (type: string, fn: () => void) => {
|
|
154
|
+
const seen = listeners.get(type)
|
|
155
|
+
if (seen) seen.push(fn)
|
|
156
|
+
else listeners.set(type, [fn])
|
|
157
|
+
})
|
|
149
158
|
define('PerformanceObserver', undefined)
|
|
150
159
|
define('hzDNT', undefined)
|
|
151
160
|
define('doNotTrack', undefined)
|
|
@@ -166,7 +175,16 @@ function runSnippet(opts: StubOptions = {}): {
|
|
|
166
175
|
define('window', g)
|
|
167
176
|
|
|
168
177
|
new Function(SRC)()
|
|
169
|
-
|
|
178
|
+
/** Raise an event the page would raise, so its listeners actually run. */
|
|
179
|
+
const fire = (type: string) => {
|
|
180
|
+
for (const fn of listeners.get(type) ?? []) fn()
|
|
181
|
+
}
|
|
182
|
+
/** Hide the document, as a browser does before it takes one away. */
|
|
183
|
+
const hide = () => {
|
|
184
|
+
;(g.document as { visibilityState: string }).visibilityState = 'hidden'
|
|
185
|
+
fire('visibilitychange')
|
|
186
|
+
}
|
|
187
|
+
return { posts, api: (g.window as { hanzo?: Api }).hanzo, local, jar, fire, hide }
|
|
170
188
|
}
|
|
171
189
|
|
|
172
190
|
/** Every event across every transmission, in order. */
|
|
@@ -178,6 +196,29 @@ describe('hz.js', () => {
|
|
|
178
196
|
run = runSnippet()
|
|
179
197
|
})
|
|
180
198
|
|
|
199
|
+
// A visitor who leaves mid-funnel is the drop-off signal, and hz.js only gets
|
|
200
|
+
// to report it from a teardown listener. Neither signal fires in every browser
|
|
201
|
+
// on every path, so the tag listens for both.
|
|
202
|
+
it('flushes what is queued when the tab is hidden', () => {
|
|
203
|
+
run.api!.track('plan_clicked')
|
|
204
|
+
run.hide()
|
|
205
|
+
expect(sentOf(run).map((e) => e.event)).toContain('plan_clicked')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('flushes what is queued on pagehide, which visibilitychange need not precede', () => {
|
|
209
|
+
run.api!.track('checkout_started')
|
|
210
|
+
run.fire('pagehide')
|
|
211
|
+
expect(sentOf(run).map((e) => e.event)).toContain('checkout_started')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('sends nothing a second time when both teardown signals arrive', () => {
|
|
215
|
+
run.api!.track('plan_clicked')
|
|
216
|
+
run.hide()
|
|
217
|
+
run.fire('pagehide')
|
|
218
|
+
const clicks = sentOf(run).filter((e) => e.event === 'plan_clicked')
|
|
219
|
+
expect(clicks).toHaveLength(1)
|
|
220
|
+
})
|
|
221
|
+
|
|
181
222
|
it('mints session ids the plane admits', () => {
|
|
182
223
|
run.api!.track('checkout_started')
|
|
183
224
|
run.api!.flush()
|
|
@@ -257,13 +298,24 @@ describe('hz.js', () => {
|
|
|
257
298
|
// keyed static surface therefore sent UNATTRIBUTED writes, which the door
|
|
258
299
|
// refuses — silently, because nothing here reads the response.
|
|
259
300
|
|
|
260
|
-
|
|
301
|
+
// The fetch presents it on the SAME carrier the beacon does, and stays a CORS
|
|
302
|
+
// simple request doing so. This file's whole job is to run on a customer's own
|
|
303
|
+
// page, where every send is cross-origin: an Authorization header or a JSON
|
|
304
|
+
// content type would preflight the POST, and an origin that does not pass the
|
|
305
|
+
// preflight loses the batch that splice has already emptied. A simple request is
|
|
306
|
+
// sent from any origin — the browser asks permission to READ a cross-origin
|
|
307
|
+
// response, never to send one, and nothing here reads it.
|
|
308
|
+
it('presents the publishable key on the query, as a simple request', () => {
|
|
261
309
|
const r = runSnippet({ attrs: { 'data-publishable-key': 'pk-abc123' } })
|
|
262
310
|
r.api!.flush()
|
|
263
311
|
const post = r.posts.at(-1)!
|
|
264
312
|
expect(post.via).toBe('fetch')
|
|
265
|
-
expect(post.
|
|
266
|
-
expect(post.
|
|
313
|
+
expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
314
|
+
expect(post.headers.authorization).toBeUndefined()
|
|
315
|
+
expect(post.headers['content-type']).toBe('text/plain')
|
|
316
|
+
// Content-Type is safelisted only for three values; every other header is
|
|
317
|
+
// outside the safelist by construction, so the count is the check.
|
|
318
|
+
expect(Object.keys(post.headers)).toEqual(['content-type'])
|
|
267
319
|
})
|
|
268
320
|
|
|
269
321
|
it('still reads the retiring data-ingest-key, on a headerless beacon', () => {
|
package/src/transport.test.ts
CHANGED
|
@@ -22,6 +22,9 @@ interface Fetched {
|
|
|
22
22
|
url: string
|
|
23
23
|
headers: Record<string, string>
|
|
24
24
|
body: string
|
|
25
|
+
/** Whether the request may outlive the document that made it. A fetch without
|
|
26
|
+
* it is cancelled on teardown, which is the whole failure this file guards. */
|
|
27
|
+
keepalive?: boolean
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
// The three CORS-safelisted request content types. A POST whose body carries one
|
|
@@ -70,10 +73,16 @@ function unloadFlush(queued: boolean): { beacons: Beaconed[]; fetches: Fetched[]
|
|
|
70
73
|
}
|
|
71
74
|
},
|
|
72
75
|
)
|
|
73
|
-
define(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
define(
|
|
77
|
+
'fetch',
|
|
78
|
+
(
|
|
79
|
+
url: string,
|
|
80
|
+
init: { headers: Record<string, string>; body: string; keepalive?: boolean },
|
|
81
|
+
) => {
|
|
82
|
+
fetches.push({ url, headers: init.headers, body: init.body, keepalive: init.keepalive })
|
|
83
|
+
return Promise.resolve({ ok: true, status: 200 })
|
|
84
|
+
},
|
|
85
|
+
)
|
|
77
86
|
|
|
78
87
|
try {
|
|
79
88
|
// No `transport`, so the client builds the real DefaultTransport. The key is
|
|
@@ -120,6 +129,17 @@ describe('the unload beacon', () => {
|
|
|
120
129
|
expect(fetches).toHaveLength(1)
|
|
121
130
|
const batch = (JSON.parse(fetches[0].body) as { batch: { event?: string }[] }).batch
|
|
122
131
|
expect(batch.map((e) => e.event)).toContain('checkout_started')
|
|
123
|
-
|
|
132
|
+
|
|
133
|
+
// The fallback carries the key the SAME way the beacon it replaces did, and
|
|
134
|
+
// stays a simple request doing so — a fallback that preflights is no fallback
|
|
135
|
+
// on the customer origin this client is meant to run on.
|
|
136
|
+
expect(fetches[0].url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
137
|
+
expect(fetches[0].headers.Authorization).toBeUndefined()
|
|
138
|
+
expect(fetches[0].headers['Content-Type']).toBe('text/plain')
|
|
139
|
+
|
|
140
|
+
// The fallback is only a fallback if it survives the teardown that the beacon
|
|
141
|
+
// was there for. Without this the batch is cancelled mid-flight and the
|
|
142
|
+
// refusal was merely traded for a quieter loss.
|
|
143
|
+
expect(fetches[0].keepalive).toBe(true)
|
|
124
144
|
})
|
|
125
145
|
})
|
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.26'
|