@hanzo/event 0.3.23 → 0.3.25

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.23'
58
+ var VERSION = '0.3.25'
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'
@@ -288,12 +288,17 @@ function hzAnonId() {
288
288
  // same pair core.ts uses, so the door cannot tell the distributions apart:
289
289
  // a headerless sendBeacon puts it in the query, a fetch puts it in the
290
290
  // Authorization header.
291
+ //
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.
291
296
  try {
292
297
  if (
293
298
  navigator.sendBeacon &&
294
299
  navigator.sendBeacon(
295
300
  key ? url + '?ingest_key=' + encodeURIComponent(key) : url,
296
- new Blob([body], { type: 'application/json' }),
301
+ new Blob([body], { type: 'text/plain' }),
297
302
  )
298
303
  )
299
304
  return
@@ -466,11 +471,25 @@ function hzAnonId() {
466
471
  })
467
472
  }).observe({ type: 'layout-shift', buffered: true })
468
473
  } catch (e) {}
469
- addEventListener('visibilitychange', function () {
470
- if (document.visibilityState !== 'hidden') return
471
- if (vitals.lcp != null || vitals.cls != null) send('event', '$vitals', vitals)
474
+ // A document can be taken away on either signal, and neither one alone covers
475
+ // every browser: visibilitychange is what fires when a tab is backgrounded or
476
+ // discarded, pagehide is what fires on the navigation path where it does not.
477
+ // core.ts listens for both; this listens for both. flush() returns on an empty
478
+ // queue, so whichever arrives second finds nothing left to send.
479
+ var vitalsSent = false
480
+ function leaving() {
481
+ // The web vitals are one measurement of one page view. Hiding a tab twice
482
+ // does not make two of them.
483
+ if (!vitalsSent && (vitals.lcp != null || vitals.cls != null)) {
484
+ vitalsSent = true
485
+ send('event', '$vitals', vitals)
486
+ }
472
487
  flush()
488
+ }
489
+ addEventListener('visibilitychange', function () {
490
+ if (document.visibilityState === 'hidden') leaving()
473
491
  })
492
+ addEventListener('pagehide', leaving)
474
493
 
475
494
  // ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
476
495
  function assign(a, b) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.23",
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.",
3
+ "version": "0.3.25",
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/",
7
7
  "access": "public",
@@ -37,6 +37,13 @@
37
37
  "funnel",
38
38
  "hanzo"
39
39
  ],
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "dev": "tsup --watch",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit",
45
+ "clean": "rm -rf dist"
46
+ },
40
47
  "exports": {
41
48
  ".": {
42
49
  "import": {
@@ -77,12 +84,5 @@
77
84
  },
78
85
  "dependencies": {
79
86
  "@hanzo/events": "^0.2.1"
80
- },
81
- "scripts": {
82
- "build": "tsup",
83
- "dev": "tsup --watch",
84
- "test": "vitest run",
85
- "typecheck": "tsc --noEmit",
86
- "clean": "rm -rf dist"
87
87
  }
88
- }
88
+ }
package/src/core.ts CHANGED
@@ -81,6 +81,13 @@ export { VERSION }
81
81
  const EVENT_PATH = '/v1/event' // the ONE canonical ingestion front door
82
82
  const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
83
83
  const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
84
+ // The beacon body's type. text/plain is CORS-SAFELISTED, which is the whole
85
+ // property: a safelisted type makes the POST a SIMPLE request, and a simple
86
+ // request needs no preflight. An unloading document does not get a second round
87
+ // trip, so cross-origin a preflighted beacon is never sent at all. The door reads
88
+ // the raw body and dispatches on its first non-space byte, so the type names the
89
+ // CORS class and nothing else.
90
+ const BEACON_CONTENT_TYPE = 'text/plain'
84
91
 
85
92
  /** readEnvDsn resolves a DSN from the public env when config omits one, so an app
86
93
  * gets the error plane by setting ONE build-time variable and nothing else.
@@ -165,7 +172,12 @@ function serializeBatch(batch: WireEvent[]): string | null {
165
172
  /** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
166
173
  * navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a
167
174
  * publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot
168
- * set headers — a publishable key rides the ?ingest_key query instead. */
175
+ * set headers — a publishable key rides the ?ingest_key query instead.
176
+ *
177
+ * `contentType` names the FETCH request's Content-Type (the error plane sets the
178
+ * envelope type there). The beacon body is always BEACON_CONTENT_TYPE: its type
179
+ * is a CORS class, not a payload description, and a beacon that is not a simple
180
+ * request is a beacon that is never sent. */
169
181
  class DefaultTransport implements Transport {
170
182
  send(
171
183
  url: string,
@@ -178,18 +190,26 @@ class DefaultTransport implements Transport {
178
190
  debug?: boolean
179
191
  },
180
192
  ): void {
181
- const contentType = opts.contentType ?? 'application/json'
182
193
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
183
194
  const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url
184
195
  try {
185
- navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }))
186
- return
196
+ // The answer is whether the agent QUEUED the batch — false for a body past
197
+ // the beacon size limit, or a full queue. Only a queued batch ends the
198
+ // send; a refused one falls through to the keepalive fetch below.
199
+ const queued = navigator.sendBeacon(
200
+ beaconUrl,
201
+ new Blob([body], { type: BEACON_CONTENT_TYPE }),
202
+ )
203
+ if (queued) return
204
+ if (opts.debug) console.warn('[event] beacon refused, falling back to fetch')
187
205
  } catch {
188
206
  /* fall through to fetch */
189
207
  }
190
208
  }
191
209
  if (typeof fetch !== 'function') return
192
- const headers: Record<string, string> = { 'Content-Type': contentType }
210
+ const headers: Record<string, string> = {
211
+ 'Content-Type': opts.contentType ?? 'application/json',
212
+ }
193
213
  const bearer = opts.ingestKey ?? opts.token
194
214
  if (bearer) headers.Authorization = `Bearer ${bearer}`
195
215
  void fetch(url, {
package/src/hz.test.ts CHANGED
@@ -27,14 +27,27 @@ interface WireEvent {
27
27
  libraryVersion: string
28
28
  }
29
29
 
30
- /** One recorded transmission: which transport carried it, where, under what headers. */
30
+ /** One recorded transmission: which transport carried it, where, under what headers
31
+ * and — the fact that decides whether a cross-origin unload beacon is sent at all
32
+ * — the content type its body was labelled with. */
31
33
  interface Post {
32
34
  via: 'beacon' | 'fetch'
33
35
  url: string
34
36
  headers: Record<string, string>
37
+ contentType: string
35
38
  batch: WireEvent[]
36
39
  }
37
40
 
41
+ // The three CORS-safelisted request content types. A POST whose body carries one
42
+ // is a SIMPLE request and goes immediately; anything else is PREFLIGHTED, and an
43
+ // unloading document never gets the preflight's second round trip. This tag runs
44
+ // on a customer's own domain, so its beacon is always cross-origin.
45
+ const CORS_SAFELISTED = new Set([
46
+ 'text/plain',
47
+ 'application/x-www-form-urlencoded',
48
+ 'multipart/form-data',
49
+ ])
50
+
38
51
  interface StubOptions {
39
52
  /** data-* attributes on the <script> tag. */
40
53
  attrs?: Record<string, string>
@@ -62,8 +75,11 @@ function runSnippet(opts: StubOptions = {}): {
62
75
  api: Api | undefined
63
76
  local: Map<string, string>
64
77
  jar: Map<string, string>
78
+ fire: (type: string) => void
79
+ hide: () => void
65
80
  } {
66
81
  const posts: Post[] = []
82
+ const listeners = new Map<string, (() => void)[]>()
67
83
  const local = new Map<string, string>(Object.entries(opts.storage ?? {}))
68
84
  const jar = opts.jar ?? new Map<string, string>()
69
85
  const store = (m: Map<string, string>) => ({
@@ -106,8 +122,14 @@ function runSnippet(opts: StubOptions = {}): {
106
122
  doNotTrack: '0',
107
123
  ...opts.navigator,
108
124
  sendBeacon: opts.beacon
109
- ? (url: string, blob: { body: string }) => {
110
- posts.push({ via: 'beacon', url, headers: {}, batch: JSON.parse(blob.body).batch })
125
+ ? (url: string, blob: { body: string; type: string }) => {
126
+ posts.push({
127
+ via: 'beacon',
128
+ url,
129
+ headers: {},
130
+ contentType: blob.type,
131
+ batch: JSON.parse(blob.body).batch,
132
+ })
111
133
  return true
112
134
  }
113
135
  : undefined,
@@ -116,15 +138,23 @@ function runSnippet(opts: StubOptions = {}): {
116
138
  'Blob',
117
139
  class {
118
140
  body: string
119
- constructor(parts: string[]) {
141
+ type: string
142
+ constructor(parts: string[], opts?: { type?: string }) {
120
143
  this.body = parts.join('')
144
+ this.type = opts?.type ?? ''
121
145
  }
122
146
  },
123
147
  )
124
148
  define('localStorage', store(local))
125
149
  define('sessionStorage', store(new Map()))
126
150
  define('history', { pushState: () => {}, replaceState: () => {} })
127
- 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
+ })
128
158
  define('PerformanceObserver', undefined)
129
159
  define('hzDNT', undefined)
130
160
  define('doNotTrack', undefined)
@@ -133,13 +163,28 @@ function runSnippet(opts: StubOptions = {}): {
133
163
  // "the previous run's API answered".
134
164
  define('hanzo', undefined)
135
165
  define('fetch', (url: string, init: { body: string; headers: Record<string, string> }) => {
136
- posts.push({ via: 'fetch', url, headers: init.headers, batch: JSON.parse(init.body).batch })
166
+ posts.push({
167
+ via: 'fetch',
168
+ url,
169
+ headers: init.headers,
170
+ contentType: init.headers['content-type'] ?? '',
171
+ batch: JSON.parse(init.body).batch,
172
+ })
137
173
  return Promise.resolve()
138
174
  })
139
175
  define('window', g)
140
176
 
141
177
  new Function(SRC)()
142
- 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 }
143
188
  }
144
189
 
145
190
  /** Every event across every transmission, in order. */
@@ -151,6 +196,29 @@ describe('hz.js', () => {
151
196
  run = runSnippet()
152
197
  })
153
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
+
154
222
  it('mints session ids the plane admits', () => {
155
223
  run.api!.track('checkout_started')
156
224
  run.api!.flush()
@@ -247,6 +315,19 @@ describe('hz.js', () => {
247
315
  expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
248
316
  })
249
317
 
318
+ it('labels the beacon body a CORS-simple type, so the unload POST is sent', () => {
319
+ // The tag is installed on a customer's own domain, so every send here is
320
+ // cross-origin. A non-safelisted body type preflights, and an unloading
321
+ // document gets no second round trip — the batch would simply never leave.
322
+ // The credential is in the query for the same reason: a beacon sets no
323
+ // headers, and an Authorization header preflights whatever the type is.
324
+ const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
325
+ r.api!.flush()
326
+ const post = r.posts.at(-1)!
327
+ expect(post.via).toBe('beacon')
328
+ expect(CORS_SAFELISTED.has(post.contentType)).toBe(true)
329
+ })
330
+
250
331
  it('sends no credential when no key is declared — no baked literal', () => {
251
332
  // The key is the surface's own, stamped into the tag by its deploy from KMS;
252
333
  // a bare tag carries nothing, so it is honestly keyless rather than borrowing
package/src/stack.ts ADDED
@@ -0,0 +1,144 @@
1
+ // Pure error parsing: coerce an unknown throwable into {name, message, stack},
2
+ // and parse a browser stack string into structured frames. No I/O, no globals —
3
+ // core.ts wires these to identity and transport.
4
+ //
5
+ // These frames ride the error Event to POST /v1/event so the warehouse stores
6
+ // WHERE a crash happened, not just that one did. Nothing here talks to Sentry:
7
+ // the client has one door and one credential. The frame shape is deliberately the
8
+ // conventional one (function/filename/abs_path/lineno/colno/in_app) because it is
9
+ // what every stack tool already speaks — including a future grouper built over
10
+ // the warehouse.
11
+
12
+ /** Max frames kept — well under the server's cap, plenty to identify a crash. */
13
+ const MAX_FRAMES = 50
14
+ /** Max stack lines examined, and max length of a line worth examining. Guards the
15
+ * frame regexes against a hostile `stack` string (see framesFromStack). */
16
+ const MAX_LINES = 500
17
+ const MAX_LINE_LEN = 2048
18
+
19
+ /** One parsed stack frame. */
20
+ export interface Frame {
21
+ filename?: string
22
+ function?: string
23
+ abs_path?: string
24
+ lineno?: number
25
+ colno?: number
26
+ /** The app's own code, as opposed to vendor/runtime — the useful default filter. */
27
+ in_app?: boolean
28
+ }
29
+
30
+ const V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/
31
+ const MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/
32
+
33
+ /** inApp marks a frame as the app's own code (vs vendor/runtime). */
34
+ function inApp(file: string): boolean {
35
+ if (!file) return false
36
+ return !(
37
+ file.includes('node_modules') ||
38
+ file.startsWith('webpack-internal') ||
39
+ file.startsWith('webpack://') ||
40
+ file.startsWith('chrome-extension://') ||
41
+ file.startsWith('moz-extension://')
42
+ )
43
+ }
44
+
45
+ /**
46
+ * framesFromStack parses a browser Error.stack into frames, OLDEST-FIRST (caller
47
+ * → callee, so the crash site is LAST). Handles both V8 ("at fn (file:li:co)")
48
+ * and Firefox/Safari ("fn@file:li:co"). Unparseable lines are skipped.
49
+ */
50
+ export function framesFromStack(stack: string | undefined): Frame[] {
51
+ if (!stack) return []
52
+ // Both frame regexes use lazy nested quantifiers, which backtrack badly on a
53
+ // long line that never matches. A stack is attacker-influenced (a thrown value
54
+ // can carry any `stack` string), so bound the work: skip absurd lines and stop
55
+ // after MAX_LINES. Only the innermost MAX_FRAMES are kept anyway.
56
+ const lines = stack.split('\n', MAX_LINES)
57
+ const frames: Frame[] = []
58
+ for (const raw of lines) {
59
+ if (raw.length > MAX_LINE_LEN) continue
60
+ const line = raw.trimEnd()
61
+ if (!line) continue
62
+ // Header lines like "TypeError: x is not a function" match neither frame
63
+ // regex (no "at " prefix, no trailing :line:col) and are skipped naturally.
64
+ let fn: string | undefined
65
+ let file = ''
66
+ let lineno = 0
67
+ let colno = 0
68
+ const v = V8_FRAME.exec(line)
69
+ if (v) {
70
+ fn = v[1]
71
+ if (v[2]) {
72
+ file = v[2]
73
+ lineno = Number(v[3]) || 0
74
+ colno = Number(v[4]) || 0
75
+ } else {
76
+ file = (v[5] || '').trim()
77
+ }
78
+ } else {
79
+ const f = MOZ_FRAME.exec(line)
80
+ if (!f) continue
81
+ fn = f[1]
82
+ file = f[2]
83
+ lineno = Number(f[3]) || 0
84
+ colno = Number(f[4]) || 0
85
+ }
86
+ if (!file && !fn) continue
87
+ frames.push({
88
+ function: fn || '<anonymous>',
89
+ filename: file,
90
+ abs_path: file,
91
+ lineno,
92
+ colno,
93
+ in_app: inApp(file),
94
+ })
95
+ }
96
+ // Reverse to oldest-first and cap to the innermost MAX_FRAMES.
97
+ frames.reverse()
98
+ if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES)
99
+ return frames
100
+ }
101
+
102
+ /** read pulls a property off a value that may be hostile — `name`, `message` and
103
+ * `stack` are ordinary getters that a thrown object is free to define as
104
+ * throwing. The thrown value is the least trustworthy input this library
105
+ * handles; losing the whole report to one of them is not acceptable. */
106
+ function read(o: unknown, k: string): unknown {
107
+ try {
108
+ return (o as Record<string, unknown>)[k]
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** str coerces to a string without letting a throwing toString/Symbol.toPrimitive
115
+ * escape. */
116
+ function str(v: unknown): string {
117
+ try {
118
+ return String(v)
119
+ } catch {
120
+ return '[unstringifiable]'
121
+ }
122
+ }
123
+
124
+ /** normalizeError coerces an unknown throwable into {name, message, stack}.
125
+ * TOTAL: it returns a usable record for ANY input, including an object
126
+ * engineered to throw on property access. */
127
+ export function normalizeError(err: unknown): { name: string; message: string; stack?: string } {
128
+ if (err instanceof Error) {
129
+ const name = read(err, 'name')
130
+ const message = read(err, 'message')
131
+ const stack = read(err, 'stack')
132
+ return {
133
+ name: typeof name === 'string' && name ? name : 'Error',
134
+ message: typeof message === 'string' && message ? message : str(err),
135
+ stack: typeof stack === 'string' ? stack : undefined,
136
+ }
137
+ }
138
+ if (typeof err === 'string') return { name: 'Error', message: err }
139
+ try {
140
+ return { name: 'Error', message: JSON.stringify(err) ?? str(err) }
141
+ } catch {
142
+ return { name: 'Error', message: str(err) }
143
+ }
144
+ }
@@ -0,0 +1,139 @@
1
+ // The DEFAULT transport — what every surface that does not supply its own gets,
2
+ // exercised through the real `Analytics` rather than a stub, because the two
3
+ // properties below are properties of the shipped object and nothing else.
4
+ //
5
+ // Both are invisible to a test that reads the batch: the body is identical either
6
+ // way. What decides whether the batch ARRIVES is the beacon's content type (which
7
+ // decides whether the browser sends it at all) and what the code does with
8
+ // sendBeacon's answer (which decides whether a refusal is retried or dropped).
9
+
10
+ import { describe, it, expect } from 'vitest'
11
+ import { Analytics } from './core'
12
+
13
+ /** One thing navigator.sendBeacon was handed. */
14
+ interface Beaconed {
15
+ url: string
16
+ type: string
17
+ body: string
18
+ }
19
+
20
+ /** One thing fetch was handed. */
21
+ interface Fetched {
22
+ url: string
23
+ headers: Record<string, string>
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
28
+ }
29
+
30
+ // The three CORS-safelisted request content types. A POST whose body carries one
31
+ // of these is a SIMPLE request and is sent immediately; anything else is
32
+ // PREFLIGHTED, and an unloading document never gets the preflight's second round
33
+ // trip — so cross-origin, a non-safelisted beacon is not delayed, it is lost.
34
+ const CORS_SAFELISTED = new Set([
35
+ 'text/plain',
36
+ 'application/x-www-form-urlencoded',
37
+ 'multipart/form-data',
38
+ ])
39
+
40
+ /** Runs ONE unload flush of the real DefaultTransport against a stub browser.
41
+ * `queued` is what navigator.sendBeacon answers — true when the agent accepts the
42
+ * batch, false when it refuses (a body past the beacon size limit, or a full
43
+ * queue). Globals are DEFINED and restored: Node ships a real `navigator` whose
44
+ * descriptor has no setter, so a plain assignment throws. */
45
+ function unloadFlush(queued: boolean): { beacons: Beaconed[]; fetches: Fetched[] } {
46
+ const beacons: Beaconed[] = []
47
+ const fetches: Fetched[] = []
48
+ const g = globalThis as Record<string, unknown>
49
+ const names = ['window', 'document', 'navigator', 'Blob', 'fetch']
50
+ const saved = names.map((n) => [n, Object.getOwnPropertyDescriptor(g, n)] as const)
51
+ const define = (name: string, value: unknown) =>
52
+ Object.defineProperty(g, name, { value, configurable: true, writable: true })
53
+
54
+ define('window', {
55
+ location: { href: 'https://acme.test/checkout', pathname: '/checkout', search: '' },
56
+ addEventListener: () => {},
57
+ })
58
+ define('document', { referrer: '', visibilityState: 'visible' })
59
+ define('navigator', {
60
+ sendBeacon: (url: string, blob: { type: string; body: string }) => {
61
+ beacons.push({ url, type: blob.type, body: blob.body })
62
+ return queued
63
+ },
64
+ })
65
+ define(
66
+ 'Blob',
67
+ class {
68
+ type: string
69
+ body: string
70
+ constructor(parts: string[], opts?: { type?: string }) {
71
+ this.body = parts.join('')
72
+ this.type = opts?.type ?? ''
73
+ }
74
+ },
75
+ )
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
+ )
86
+
87
+ try {
88
+ // No `transport`, so the client builds the real DefaultTransport. The key is
89
+ // explicit rather than inherited from the build env, so the assertions below
90
+ // are about this batch and not about the machine running them.
91
+ const a = new Analytics({ product: 'test', ingestKey: 'pk-abc123', captureErrors: false })
92
+ a.capture('checkout_started')
93
+ a.flush(true)
94
+ } finally {
95
+ for (const [n, d] of saved) {
96
+ if (d) Object.defineProperty(g, n, d)
97
+ else delete g[n]
98
+ }
99
+ }
100
+ return { beacons, fetches }
101
+ }
102
+
103
+ describe('the unload beacon', () => {
104
+ it('is a CORS-simple request, so an unloading document actually sends it', () => {
105
+ const { beacons, fetches } = unloadFlush(true)
106
+ expect(beacons).toHaveLength(1)
107
+
108
+ // The load-bearing assertion. A non-safelisted type preflights, and there is
109
+ // no second round trip during unload.
110
+ expect(CORS_SAFELISTED.has(beacons[0].type)).toBe(true)
111
+
112
+ // The other half of what makes it simple: a beacon can set no headers, so the
113
+ // credential rides the query. An Authorization header would preflight whatever
114
+ // the body's type is.
115
+ expect(beacons[0].url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
116
+
117
+ // A queued batch is sent ONCE. Falling through here would double-count every
118
+ // unload event in the warehouse.
119
+ expect(fetches).toHaveLength(0)
120
+ })
121
+
122
+ it('falls through to the keepalive fetch when the agent refuses to queue it', () => {
123
+ const { beacons, fetches } = unloadFlush(false)
124
+
125
+ // Offered to the beacon first...
126
+ expect(beacons).toHaveLength(1)
127
+
128
+ // ...and, refused, carried by the fetch sitting behind it rather than dropped.
129
+ expect(fetches).toHaveLength(1)
130
+ const batch = (JSON.parse(fetches[0].body) as { batch: { event?: string }[] }).batch
131
+ expect(batch.map((e) => e.event)).toContain('checkout_started')
132
+ expect(fetches[0].headers.Authorization).toBe('Bearer pk-abc123')
133
+
134
+ // The fallback is only a fallback if it survives the teardown that the beacon
135
+ // was there for. Without this the batch is cancelled mid-flight and the
136
+ // refusal was merely traded for a quieter loss.
137
+ expect(fetches[0].keepalive).toBe(true)
138
+ })
139
+ })
package/src/types.ts CHANGED
@@ -139,8 +139,10 @@ export interface WireEvent {
139
139
  * ?ingest_key query. */
140
140
  export interface Transport {
141
141
  /** Durable POST usable during page unload (fetch keepalive / sendBeacon).
142
- * `contentType` defaults to application/json; the error plane overrides it with
143
- * application/x-sentry-envelope. */
142
+ * `contentType` names the FETCH request's Content-Type it defaults to
143
+ * application/json, and the error plane sets application/x-sentry-envelope. A
144
+ * beacon body carries a CORS-safelisted type so the POST stays a simple request;
145
+ * that is a property of the transport, not a caller's choice. */
144
146
  send(
145
147
  url: string,
146
148
  body: string,
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.23'
4
+ export const VERSION = '0.3.25'
package/LICENSE.md DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2023 hanzo
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.