@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/hz.js CHANGED
@@ -55,7 +55,7 @@
55
55
  }
56
56
 
57
57
  var LIB = 'hz.js'
58
- var VERSION = '0.3.24'
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
- // The key rides the two channels each transport can actually carry the
288
- // same pair core.ts uses, so the door cannot tell the distributions apart:
289
- // a headerless sendBeacon puts it in the query, a fetch puts it in the
290
- // Authorization header.
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
- // The beacon body is text/plain because that type is CORS-safelisted, which
293
- // makes the POST a SIMPLE request: no preflight, and an unloading document
294
- // gets no second round trip. The door reads the raw body and dispatches on
295
- // its first non-space byte, so the type names the CORS class and nothing else.
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
- var headers = { 'content-type': 'application/json' }
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
- headers: headers,
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
- addEventListener('visibilitychange', function () {
475
- if (document.visibilityState !== 'hidden') return
476
- if (vitals.lcp != null || vitals.cls != null) send('event', '$vitals', vitals)
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.24",
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
- define('addEventListener', () => {})
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
- return { posts, api: (g.window as { hanzo?: Api }).hanzo, local, jar }
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
- it('presents the publishable key as a bearer on fetch', () => {
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.headers.authorization).toBe('Bearer pk-abc123')
266
- expect(post.url).toBe('https://api.hanzo.ai/v1/event')
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', () => {
@@ -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('fetch', (url: string, init: { headers: Record<string, string>; body: string }) => {
74
- fetches.push({ url, headers: init.headers, body: init.body })
75
- return Promise.resolve({ ok: true, status: 200 })
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
- expect(fetches[0].headers.Authorization).toBe('Bearer pk-abc123')
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.24'
4
+ export const VERSION = '0.3.26'