@hanzo/event 0.3.32 → 0.3.33
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/README.md +17 -19
- package/dist/index.cjs +14 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +14 -14
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +14 -14
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +14 -14
- package/dist/react.mjs.map +1 -1
- package/package.json +1 -2
- package/src/anon.d.ts +1 -1
- package/src/anon.js +18 -18
- package/src/anon.test.ts +17 -38
- package/src/stack.ts +144 -0
- package/src/storage.test.ts +2 -2
- package/src/storage.ts +1 -1
- package/src/uid.ts +1 -1
- package/src/version.ts +1 -1
- package/hz.js +0 -511
- package/src/hz.test.ts +0 -376
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.33",
|
|
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/",
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"src",
|
|
20
|
-
"hz.js",
|
|
21
20
|
"README.md",
|
|
22
21
|
"TAXONOMY.md"
|
|
23
22
|
],
|
package/src/anon.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Types for anon.js, which is hand-written ES5 rather than TypeScript because
|
|
2
|
-
//
|
|
2
|
+
// The door's hosted tag inlines it VERBATIM and has no compiler.
|
|
3
3
|
// The declarations are here so the bundled client still imports it typed.
|
|
4
4
|
|
|
5
5
|
/** Mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch milliseconds. */
|
package/src/anon.js
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
/*! anon.js — THE anonymous-identity chain. ONE implementation,
|
|
1
|
+
/*! anon.js — THE anonymous-identity chain. ONE implementation, two distributions.
|
|
2
2
|
*
|
|
3
3
|
* One browser is ONE person on every Hanzo surface, whichever client a page
|
|
4
|
-
* happens to have loaded. There were three implementations writing TWO keys —
|
|
5
|
-
* `hz_anon_id`
|
|
6
|
-
*
|
|
4
|
+
* happens to have loaded. There were once three implementations writing TWO keys —
|
|
5
|
+
* `hz_anon_id` and `hz_id` — so the same visitor was several people depending on
|
|
6
|
+
* which snippet the surface shipped.
|
|
7
7
|
*
|
|
8
|
-
* The
|
|
8
|
+
* The two call sites:
|
|
9
9
|
* 1. src/storage.ts — the bundled npm client; IMPORTS this file.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3. hanzoai/cloud apps/analytics/tag.js — the tag the door hosts at
|
|
10
|
+
* 2. hanzoai/cloud apps/analytics/tag.js — the tag the door hosts at
|
|
12
11
|
* /v1/event.js; vendors this file and its tag.go serves the marked region
|
|
13
12
|
* with the tag as one asset, so the door holds no second copy either.
|
|
14
13
|
*
|
|
15
|
-
* (2)
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* (2) has no bundler and cannot import anything, which is why the chain lives in a
|
|
15
|
+
* file that is plain ES5 rather than in a .ts: the region between the BEGIN and END
|
|
16
|
+
* markers is COPIED VERBATIM. Keep it ES5, dependency-free, `hz`-prefixed (it is
|
|
17
|
+
* spliced into other people's scopes) and unformatted — a reformat here is a diff
|
|
18
|
+
* against the vendored copy. After editing, resync the door:
|
|
19
|
+
*
|
|
20
|
+
* curl -fsSL https://unpkg.com/@hanzo/event/src/anon.js -o apps/analytics/anon.js
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
/* ── BEGIN hz anon chain — copied VERBATIM into
|
|
23
|
+
/* ── BEGIN hz anon chain — copied VERBATIM into hanzoai/cloud ──────────────── */
|
|
24
24
|
|
|
25
25
|
/** The ONE anonymous-id key, on every surface and in every distribution. */
|
|
26
26
|
var HZ_ANON_KEY = 'hz_anon_id'
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
/** `hz_id` is a SECOND identity space a no-build tag once wrote, so one page could
|
|
29
|
+
* hold two people. It is READ and never written: an id already in the wild is
|
|
30
|
+
* ADOPTED into the shared identity, because minting over one detaches a returning
|
|
31
|
+
* visitor from their own history. */
|
|
32
32
|
var HZ_ANON_LEGACY_KEY = 'hz_id'
|
|
33
33
|
|
|
34
34
|
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
|
package/src/anon.test.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
// The anonymous-identity chain is ONE implementation with
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// different key names.
|
|
1
|
+
// The anonymous-identity chain is ONE implementation with two call sites, and one
|
|
2
|
+
// of them cannot import it: the tag hanzoai/cloud hosts at /v1/event.js has no
|
|
3
|
+
// bundler, so it carries the marked region of anon.js VERBATIM. That is the drift
|
|
4
|
+
// risk this file exists to remove — snippets that agree the day they are written
|
|
5
|
+
// and disagree a quarter later, which is exactly how the same browser came to hold
|
|
6
|
+
// two different anonymous ids under two different key names.
|
|
8
7
|
//
|
|
9
|
-
// The behavioural contract is proven where
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// two suites testing one implementation instead of two.
|
|
8
|
+
// The behavioural contract is proven where the chain runs: storage.test.ts. This
|
|
9
|
+
// file proves the region is SPLICEABLE and that it ships, which is what lets the
|
|
10
|
+
// door vendor it instead of writing a second one.
|
|
13
11
|
|
|
14
12
|
import { describe, expect, it } from 'vitest'
|
|
15
13
|
import { readFileSync } from 'node:fs'
|
|
@@ -32,37 +30,19 @@ function span(src: string, what: string): [number, number] {
|
|
|
32
30
|
/** The shared region, markers included. */
|
|
33
31
|
const region = (src: string, what: string): string => src.slice(...span(src, what))
|
|
34
32
|
|
|
35
|
-
/** Everything that is NOT the shared region. */
|
|
36
|
-
function outside(src: string, what: string): string {
|
|
37
|
-
const [b, e] = span(src, what)
|
|
38
|
-
return src.slice(0, b) + src.slice(e)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
33
|
const ANON = read('./anon.js')
|
|
42
|
-
const HZ = read('../hz.js')
|
|
43
34
|
|
|
44
35
|
describe('the shared anon chain', () => {
|
|
45
|
-
it('is
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
expect(region(HZ, 'hz.js')).toBe(region(ANON, 'anon.js'))
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('names one key, and hz.js holds no identity code of its own', () => {
|
|
54
|
-
// hz.js used to mint into hz_id — a second identity space, so the one-paste
|
|
55
|
-
// tag and the npm client were two people on one page. Neither key may be
|
|
56
|
-
// NAMED outside the shared region: a snippet that spells a key is a snippet
|
|
57
|
-
// that has an opinion about identity, and there is one opinion now.
|
|
58
|
-
const rest = outside(HZ, 'hz.js')
|
|
59
|
-
expect(rest).not.toContain("'hz_anon_id'")
|
|
60
|
-
expect(rest).not.toContain("'hz_id'")
|
|
36
|
+
it('is marked, so the door can splice exactly it', () => {
|
|
37
|
+
// cloud's tag.go slices between these markers and PANICS if it cannot find
|
|
38
|
+
// them, so losing a marker ships a tag whose every event carries an undefined
|
|
39
|
+
// identity. To change the chain, edit this file; never patch a copy.
|
|
40
|
+
expect(region(ANON, 'anon.js')).toContain(BEGIN)
|
|
61
41
|
})
|
|
62
42
|
|
|
63
43
|
it('is inlineable: the region imports nothing and declares no ES6', () => {
|
|
64
|
-
// It is spliced into
|
|
65
|
-
//
|
|
44
|
+
// It is spliced into a file that has no bundler and into pages the client does
|
|
45
|
+
// not control, so it must be ES5, self-contained and hz-prefixed.
|
|
66
46
|
const r = region(ANON, 'anon.js')
|
|
67
47
|
expect(r).not.toMatch(/\b(?:import|export|const|let|class|=>)\b|=>/)
|
|
68
48
|
for (const [, name] of r.matchAll(/^(?:function|var) ([A-Za-z_$][\w$]*)/gm)) {
|
|
@@ -72,9 +52,8 @@ describe('the shared anon chain', () => {
|
|
|
72
52
|
|
|
73
53
|
it('ships in the published package, for the door to vendor', () => {
|
|
74
54
|
// hanzoai/cloud vendors this file whole and its tag.go serves the region with
|
|
75
|
-
// the tag as one asset, so the
|
|
55
|
+
// the tag as one asset, so the second call site holds no copy of its own.
|
|
76
56
|
const pkg = JSON.parse(read('../package.json')) as { files: string[] }
|
|
77
57
|
expect(pkg.files).toContain('src')
|
|
78
|
-
expect(pkg.files).toContain('hz.js')
|
|
79
58
|
})
|
|
80
59
|
})
|
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
|
+
}
|
package/src/storage.test.ts
CHANGED
|
@@ -113,8 +113,8 @@ describe('anonId', () => {
|
|
|
113
113
|
}
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
it("adopts
|
|
117
|
-
//
|
|
116
|
+
it("adopts a legacy `hz_id` when there is no canonical id to find", async () => {
|
|
117
|
+
// A no-build tag once minted into a key of its own, so a browser that met it
|
|
118
118
|
// first already carries an identity — under a different name. Minting here
|
|
119
119
|
// would make that visitor a stranger the moment they reach a bundled surface,
|
|
120
120
|
// which is precisely the split this migration closes.
|
package/src/storage.ts
CHANGED
|
@@ -30,7 +30,7 @@ function ls(): Storage | undefined {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Stable anonymous id, shared by every *.hanzo.ai surface AND by every Hanzo
|
|
33
|
-
* client on the page — the npm client
|
|
33
|
+
* client on the page — the npm client and the hosted tag both run the one
|
|
34
34
|
* chain in ./anon.js, so which snippet a surface loaded no longer decides who the
|
|
35
35
|
* visitor is.
|
|
36
36
|
*
|
package/src/uid.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The implementation is `hzUuidv7` in ./anon.js and this file only re-exports it.
|
|
4
4
|
// It lives there because the anonymous-id chain has to mint too, and that chain is
|
|
5
|
-
// inlined verbatim by
|
|
5
|
+
// inlined verbatim by the distribution that has no bundler (the tag
|
|
6
6
|
// the door hosts) — a minter here as well would be a second implementation, and
|
|
7
7
|
// the version nibble it produces is exactly the thing that must never diverge.
|
|
8
8
|
//
|
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.33'
|