@hanzo/event 0.3.24 → 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.24'
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'
@@ -471,11 +471,25 @@ function hzAnonId() {
471
471
  })
472
472
  }).observe({ type: 'layout-shift', buffered: true })
473
473
  } catch (e) {}
474
- addEventListener('visibilitychange', function () {
475
- if (document.visibilityState !== 'hidden') return
476
- 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
+ }
477
487
  flush()
488
+ }
489
+ addEventListener('visibilitychange', function () {
490
+ if (document.visibilityState === 'hidden') leaving()
478
491
  })
492
+ addEventListener('pagehide', leaving)
479
493
 
480
494
  // ── public API (manual funnel/identify) + GA/Meta fan-out ─────────────────
481
495
  function assign(a, b) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.24",
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/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()
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
+ }
@@ -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
@@ -121,5 +130,10 @@ describe('the unload beacon', () => {
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
  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)
124
138
  })
125
139
  })
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.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.