@hanzo/event 0.3.25 → 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/LICENSE.md +21 -0
- 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 +19 -20
- package/package.json +10 -10
- package/src/core.ts +12 -4
- package/src/hz.test.ts +14 -3
- package/src/transport.test.ts +7 -1
- package/src/version.ts +1 -1
- package/src/stack.ts +0 -144
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 ────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Hanzo Event
|
|
3
|
+
"version": "0.3.26",
|
|
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/",
|
|
7
7
|
"access": "public",
|
|
@@ -37,13 +37,6 @@
|
|
|
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
|
-
},
|
|
47
40
|
"exports": {
|
|
48
41
|
".": {
|
|
49
42
|
"import": {
|
|
@@ -84,5 +77,12 @@
|
|
|
84
77
|
},
|
|
85
78
|
"dependencies": {
|
|
86
79
|
"@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
|
@@ -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
|
@@ -298,13 +298,24 @@ describe('hz.js', () => {
|
|
|
298
298
|
// keyed static surface therefore sent UNATTRIBUTED writes, which the door
|
|
299
299
|
// refuses — silently, because nothing here reads the response.
|
|
300
300
|
|
|
301
|
-
|
|
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', () => {
|
|
302
309
|
const r = runSnippet({ attrs: { 'data-publishable-key': 'pk-abc123' } })
|
|
303
310
|
r.api!.flush()
|
|
304
311
|
const post = r.posts.at(-1)!
|
|
305
312
|
expect(post.via).toBe('fetch')
|
|
306
|
-
expect(post.
|
|
307
|
-
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'])
|
|
308
319
|
})
|
|
309
320
|
|
|
310
321
|
it('still reads the retiring data-ingest-key, on a headerless beacon', () => {
|
package/src/transport.test.ts
CHANGED
|
@@ -129,7 +129,13 @@ describe('the unload beacon', () => {
|
|
|
129
129
|
expect(fetches).toHaveLength(1)
|
|
130
130
|
const batch = (JSON.parse(fetches[0].body) as { batch: { event?: string }[] }).batch
|
|
131
131
|
expect(batch.map((e) => e.event)).toContain('checkout_started')
|
|
132
|
-
|
|
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')
|
|
133
139
|
|
|
134
140
|
// The fallback is only a fallback if it survives the teardown that the beacon
|
|
135
141
|
// was there for. Without this the batch is cancelled mid-flight and the
|
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'
|
package/src/stack.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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
|
-
}
|