@hanzo/event 0.2.0 → 0.3.0
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 +54 -7
- package/dist/{core-DDGwms7M.d.cts → core-CrbiAQhN.d.cts} +48 -19
- package/dist/{core-DDGwms7M.d.ts → core-CrbiAQhN.d.ts} +48 -19
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +35 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +35 -19
- package/dist/index.mjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +35 -19
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +35 -19
- package/dist/react.mjs.map +1 -1
- package/package.json +10 -10
- package/src/core.test.ts +84 -18
- package/src/core.ts +74 -30
- package/src/types.ts +27 -10
- package/LICENSE.md +0 -21
package/src/core.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { Analytics } from './core'
|
|
2
|
+
import { Analytics, VERSION } from './core'
|
|
3
3
|
import { EVENTS, PAGEVIEW } from './events'
|
|
4
4
|
import type { Transport, WireEvent } from './types'
|
|
5
5
|
|
|
@@ -7,14 +7,25 @@ interface Sent {
|
|
|
7
7
|
url: string
|
|
8
8
|
beacon: boolean
|
|
9
9
|
token?: string
|
|
10
|
+
ingestKey?: string
|
|
11
|
+
raw: string
|
|
10
12
|
batch: WireEvent[]
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
// FakeTransport records the EXACT bytes the client would put on the wire, so the
|
|
16
|
+
// tests assert the real POST /v1/event body shape ({ batch: [...] }), not a mock.
|
|
13
17
|
class FakeTransport implements Transport {
|
|
14
18
|
sent: Sent[] = []
|
|
15
|
-
send(url: string, body: string, opts: { beacon: boolean; token?: string }) {
|
|
19
|
+
send(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }) {
|
|
16
20
|
const parsed = JSON.parse(body) as { batch: WireEvent[] }
|
|
17
|
-
this.sent.push({
|
|
21
|
+
this.sent.push({
|
|
22
|
+
url,
|
|
23
|
+
beacon: opts.beacon,
|
|
24
|
+
token: opts.token,
|
|
25
|
+
ingestKey: opts.ingestKey,
|
|
26
|
+
raw: body,
|
|
27
|
+
batch: parsed.batch,
|
|
28
|
+
})
|
|
18
29
|
}
|
|
19
30
|
get all(): WireEvent[] {
|
|
20
31
|
return this.sent.flatMap((s) => s.batch)
|
|
@@ -22,9 +33,11 @@ class FakeTransport implements Transport {
|
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
let tx: FakeTransport
|
|
36
|
+
// Default to same-origin (host:'') so path assertions read the bare /v1/event door;
|
|
37
|
+
// tests that care about the edge host pass it explicitly.
|
|
25
38
|
function mk(overrides = {}) {
|
|
26
39
|
tx = new FakeTransport()
|
|
27
|
-
return new Analytics({ product: 'console', transport: tx, flushIntervalMs: 999999, ...overrides })
|
|
40
|
+
return new Analytics({ product: 'console', host: '', transport: tx, flushIntervalMs: 999999, ...overrides })
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
describe('Analytics capture', () => {
|
|
@@ -32,23 +45,57 @@ describe('Analytics capture', () => {
|
|
|
32
45
|
tx = new FakeTransport()
|
|
33
46
|
})
|
|
34
47
|
|
|
35
|
-
it('flushes an event as
|
|
48
|
+
it('flushes an event as { batch:[…] } to /v1/event, no tenant/org field', () => {
|
|
36
49
|
const a = mk()
|
|
37
50
|
a.capture(EVENTS.SIGNUP_COMPLETED, { plan: 'pro' })
|
|
38
51
|
a.flush()
|
|
39
52
|
expect(tx.sent).toHaveLength(1)
|
|
40
|
-
expect(tx.sent[0].url).toBe('/v1/
|
|
53
|
+
expect(tx.sent[0].url).toBe('/v1/event')
|
|
54
|
+
// The body is the canonical { batch: [...] } envelope, exactly one door.
|
|
55
|
+
const parsed = JSON.parse(tx.sent[0].raw)
|
|
56
|
+
expect(Array.isArray(parsed.batch)).toBe(true)
|
|
41
57
|
const e = tx.all[0]
|
|
42
58
|
expect(e.type).toBe('event')
|
|
43
59
|
expect(e.event).toBe('signup_completed')
|
|
44
60
|
expect(e.product).toBe('console')
|
|
45
61
|
expect(e.properties).toEqual({ plan: 'pro' })
|
|
46
|
-
// The client must
|
|
62
|
+
// The client must NEVER send a tenant/org — the server stamps it.
|
|
47
63
|
expect((e as Record<string, unknown>).tenant).toBeUndefined()
|
|
48
64
|
expect((e as Record<string, unknown>).org).toBeUndefined()
|
|
49
65
|
expect((e as Record<string, unknown>).tenantId).toBeUndefined()
|
|
50
66
|
})
|
|
51
67
|
|
|
68
|
+
it('the wire is the canonical /v1/event Event: only known cloud fields, no tenant', () => {
|
|
69
|
+
const a = mk({ host: 'https://api.hanzo.ai' })
|
|
70
|
+
a.identify('user-9')
|
|
71
|
+
a.capture(EVENTS.ORDER_COMPLETED, { kind: 'plan' }, { productId: 'plan_pro', revenue: 49, quantity: 1, currency: 'usd' })
|
|
72
|
+
a.flush()
|
|
73
|
+
// ONE POST, ONE door, ONE batched envelope.
|
|
74
|
+
expect(tx.sent).toHaveLength(1)
|
|
75
|
+
expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/event')
|
|
76
|
+
const parsed = JSON.parse(tx.sent[0].raw) as { batch: WireEvent[] }
|
|
77
|
+
expect(Object.keys(parsed)).toEqual(['batch'])
|
|
78
|
+
// Every field the client emits is a known cloud CaptureEvent field — no tenant.
|
|
79
|
+
const allowed = new Set([
|
|
80
|
+
'messageId', 'type', 'event', 'timestamp', 'distinctId', 'anonymousId',
|
|
81
|
+
'personId', 'sessionId', 'product', 'url', 'path', 'referrer', 'utm',
|
|
82
|
+
'refCode', 'channel', 'groupId', 'signupWeek', 'productId', 'quantity',
|
|
83
|
+
'revenue', 'currency', 'error', 'properties', 'library', 'libraryVersion',
|
|
84
|
+
])
|
|
85
|
+
for (const ev of parsed.batch) {
|
|
86
|
+
for (const k of Object.keys(ev)) expect(allowed.has(k)).toBe(true)
|
|
87
|
+
expect((ev as Record<string, unknown>).tenant).toBeUndefined()
|
|
88
|
+
expect((ev as Record<string, unknown>).tenantId).toBeUndefined()
|
|
89
|
+
expect(ev.library).toBe('@hanzo/event')
|
|
90
|
+
expect(ev.libraryVersion).toBe(VERSION)
|
|
91
|
+
}
|
|
92
|
+
const order = parsed.batch.find((e) => e.event === EVENTS.ORDER_COMPLETED)!
|
|
93
|
+
expect(order.productId).toBe('plan_pro')
|
|
94
|
+
expect(order.revenue).toBe(49)
|
|
95
|
+
expect(order.quantity).toBe(1)
|
|
96
|
+
expect(order.currency).toBe('usd')
|
|
97
|
+
})
|
|
98
|
+
|
|
52
99
|
it('pageview emits the reserved $pageview name', () => {
|
|
53
100
|
const a = mk()
|
|
54
101
|
a.pageview('/pricing')
|
|
@@ -107,16 +154,29 @@ describe('Analytics capture', () => {
|
|
|
107
154
|
a.capture('x')
|
|
108
155
|
a.flush(true) // beacon requested…
|
|
109
156
|
expect(tx.sent[0].token).toBe('jwt-abc')
|
|
110
|
-
expect(tx.sent[0].beacon).toBe(false) // …but a
|
|
111
|
-
expect(tx.sent[0].url).toBe('/v1/
|
|
157
|
+
expect(tx.sent[0].beacon).toBe(false) // …but a JWT forces keepalive fetch
|
|
158
|
+
expect(tx.sent[0].url).toBe('/v1/event')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('cookie apps beacon to /v1/event on unload flush', () => {
|
|
162
|
+
const a = mk() // no token, no key
|
|
163
|
+
a.capture('x')
|
|
164
|
+
a.flush(true)
|
|
165
|
+
expect(tx.sent[0].beacon).toBe(true)
|
|
166
|
+
expect(tx.sent[0].token).toBeUndefined()
|
|
167
|
+
expect(tx.sent[0].url).toBe('/v1/event')
|
|
112
168
|
})
|
|
113
169
|
|
|
114
|
-
it('
|
|
115
|
-
const a = mk(
|
|
170
|
+
it('publishable-key apps ride ingestKey and still beacon on unload', () => {
|
|
171
|
+
const a = mk({ ingestKey: 'pk_live_123' })
|
|
116
172
|
a.capture('x')
|
|
117
173
|
a.flush(true)
|
|
174
|
+
// The key is offered to the transport (rides ?ingest_key on the beacon), and
|
|
175
|
+
// a publishable key does NOT block the unload beacon.
|
|
176
|
+
expect(tx.sent[0].ingestKey).toBe('pk_live_123')
|
|
177
|
+
expect(tx.sent[0].token).toBeUndefined()
|
|
118
178
|
expect(tx.sent[0].beacon).toBe(true)
|
|
119
|
-
expect(tx.sent[0].url).toBe('/v1/
|
|
179
|
+
expect(tx.sent[0].url).toBe('/v1/event')
|
|
120
180
|
})
|
|
121
181
|
|
|
122
182
|
it('setCohort rides subsequent events', () => {
|
|
@@ -134,30 +194,35 @@ describe('Analytics capture', () => {
|
|
|
134
194
|
const a = mk({ host: 'https://api.hanzo.ai' })
|
|
135
195
|
a.capture('x')
|
|
136
196
|
a.flush()
|
|
137
|
-
expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/
|
|
197
|
+
expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/event')
|
|
138
198
|
})
|
|
139
199
|
|
|
140
|
-
it('stamps every event with the @hanzo/event library id', () => {
|
|
200
|
+
it('stamps every event with the @hanzo/event library id + version', () => {
|
|
141
201
|
const a = mk()
|
|
142
202
|
a.capture('x')
|
|
143
203
|
a.flush()
|
|
144
204
|
expect(tx.all[0].library).toBe('@hanzo/event')
|
|
205
|
+
expect(tx.all[0].libraryVersion).toBe(VERSION)
|
|
145
206
|
})
|
|
146
207
|
})
|
|
147
208
|
|
|
148
209
|
describe('Event error capture', () => {
|
|
149
|
-
it('captureError emits a type:error event
|
|
210
|
+
it('captureError emits a type:error event carrying a TOP-LEVEL exception, flushed at once', () => {
|
|
150
211
|
const a = mk()
|
|
151
212
|
a.captureError(new TypeError('boom'))
|
|
152
213
|
// captureError flushes promptly — no explicit flush() needed.
|
|
153
214
|
expect(tx.sent).toHaveLength(1)
|
|
154
215
|
const e = tx.all[0]
|
|
216
|
+
// type:'error' is the field Cloud folds to event_type='error' → sentry lens.
|
|
155
217
|
expect(e.type).toBe('error')
|
|
156
218
|
expect(e.event).toBe('boom')
|
|
219
|
+
// The exception rides the TOP-LEVEL `error` field (what cloud foldException
|
|
220
|
+
// reads), NOT properties — a properties-only exception would not be lensed.
|
|
157
221
|
expect(e.error?.type).toBe('TypeError')
|
|
158
222
|
expect(e.error?.message).toBe('boom')
|
|
159
223
|
expect(e.error?.stack).toBeTruthy()
|
|
160
224
|
expect(e.error?.handled).toBe(true) // a caught, manually-reported error
|
|
225
|
+
expect((e.properties ?? {})).not.toHaveProperty('$exception')
|
|
161
226
|
})
|
|
162
227
|
|
|
163
228
|
it('normalizes a thrown string into an exception', () => {
|
|
@@ -184,11 +249,12 @@ describe('Event error capture', () => {
|
|
|
184
249
|
expect(e.error?.message).toBe('via alias')
|
|
185
250
|
})
|
|
186
251
|
|
|
187
|
-
it('an error is still an event on the ONE stream — same
|
|
252
|
+
it('an error is still an event on the ONE stream — same /v1/event door + product', () => {
|
|
188
253
|
const a = mk({ host: 'https://api.hanzo.ai' })
|
|
189
254
|
a.captureError(new Error('x'))
|
|
190
|
-
// Same batched
|
|
191
|
-
expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/
|
|
255
|
+
// Same batched door as any other event — one pipe, not a second SDK.
|
|
256
|
+
expect(tx.sent[0].url).toBe('https://api.hanzo.ai/v1/event')
|
|
257
|
+
expect(tx.sent[0].raw.startsWith('{"batch":')).toBe(true)
|
|
192
258
|
expect(tx.all[0].product).toBe('console')
|
|
193
259
|
// never the tenant — the server stamps it, errors included.
|
|
194
260
|
expect((tx.all[0] as Record<string, unknown>).tenant).toBeUndefined()
|
package/src/core.ts
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
-
// The framework-agnostic event client. Buffers events and flushes them as
|
|
2
|
-
// batch
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The framework-agnostic event client. Buffers events and flushes them as ONE
|
|
2
|
+
// batch through the ONE Hanzo Cloud ingestion front door:
|
|
3
|
+
//
|
|
4
|
+
// POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
|
|
5
|
+
//
|
|
6
|
+
// It NEVER sends the org/tenant: Cloud resolves that server-side (from the
|
|
7
|
+
// validated session, or the signed publishable key) and stamps it. The client
|
|
8
|
+
// only supplies its own visitor identity. Errors are just events (type:'error')
|
|
9
|
+
// on the same stream — one client, one pipe, lensed server-side into product
|
|
10
|
+
// analytics (insights), web analytics (analytics), and error tracking (sentry).
|
|
11
|
+
//
|
|
12
|
+
// Auth is orthogonal — the SAME body to the SAME door, differing only in how the
|
|
13
|
+
// caller proves its tenant:
|
|
14
|
+
//
|
|
15
|
+
// • cookie/session app (host:'') — same-origin credentials ride the request.
|
|
16
|
+
// • bearer app (getToken) — Authorization: Bearer <jwt>.
|
|
17
|
+
// • publishable-key app (ingestKey: 'pk_…') — Authorization: Bearer pk_… on
|
|
18
|
+
// fetch, ?ingest_key=pk_… on a headerless page-unload beacon. Write-only and
|
|
19
|
+
// safe to ship in a bundle; the door HMAC-verifies it to an org server-side.
|
|
20
|
+
//
|
|
21
|
+
// The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field
|
|
22
|
+
// is what Cloud folds to event_type='error', so a captured exception reaches the
|
|
23
|
+
// error-tracking lens. (A four-field {event,distinctId,time,properties} object has
|
|
24
|
+
// no `type`, so it can never be lensed as an error — this batched Event wire is
|
|
25
|
+
// the one that lights up all three lenses.)
|
|
6
26
|
|
|
7
27
|
import {
|
|
8
28
|
parseAttribution,
|
|
@@ -28,10 +48,16 @@ import type {
|
|
|
28
48
|
WireEvent,
|
|
29
49
|
} from './types'
|
|
30
50
|
|
|
31
|
-
export const VERSION = '0.
|
|
51
|
+
export const VERSION = '0.3.0'
|
|
32
52
|
|
|
33
|
-
const
|
|
34
|
-
const
|
|
53
|
+
const EVENT_PATH = '/v1/event' // the ONE canonical ingestion front door
|
|
54
|
+
const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
|
|
55
|
+
|
|
56
|
+
/** appendQuery adds a single query param to a URL string — used to carry a
|
|
57
|
+
* publishable key on a headerless sendBeacon (?ingest_key=…). */
|
|
58
|
+
function appendQuery(url: string, key: string, value: string): string {
|
|
59
|
+
return url + (url.includes('?') ? '&' : '?') + key + '=' + encodeURIComponent(value)
|
|
60
|
+
}
|
|
35
61
|
|
|
36
62
|
function uid(): string {
|
|
37
63
|
const c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
@@ -55,12 +81,15 @@ function normalizeError(err: unknown): Exception {
|
|
|
55
81
|
const isBrowser = () => typeof window !== 'undefined'
|
|
56
82
|
|
|
57
83
|
/** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
|
|
58
|
-
* navigator.sendBeacon for headerless page-unload beacons.
|
|
84
|
+
* navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a
|
|
85
|
+
* publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot
|
|
86
|
+
* set headers — a publishable key rides the ?ingest_key query instead. */
|
|
59
87
|
class DefaultTransport implements Transport {
|
|
60
|
-
send(url: string, body: string, opts: { beacon: boolean; token?: string }): void {
|
|
88
|
+
send(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }): void {
|
|
61
89
|
if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
|
|
90
|
+
const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url
|
|
62
91
|
try {
|
|
63
|
-
navigator.sendBeacon(
|
|
92
|
+
navigator.sendBeacon(beaconUrl, new Blob([body], { type: 'application/json' }))
|
|
64
93
|
return
|
|
65
94
|
} catch {
|
|
66
95
|
/* fall through to fetch */
|
|
@@ -68,7 +97,8 @@ class DefaultTransport implements Transport {
|
|
|
68
97
|
}
|
|
69
98
|
if (typeof fetch !== 'function') return
|
|
70
99
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
71
|
-
|
|
100
|
+
const bearer = opts.ingestKey ?? opts.token
|
|
101
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
|
72
102
|
void fetch(url, {
|
|
73
103
|
method: 'POST',
|
|
74
104
|
headers,
|
|
@@ -76,7 +106,7 @@ class DefaultTransport implements Transport {
|
|
|
76
106
|
keepalive: true,
|
|
77
107
|
credentials: 'include',
|
|
78
108
|
}).catch(() => {
|
|
79
|
-
/*
|
|
109
|
+
/* telemetry loss is acceptable; never throw into the app */
|
|
80
110
|
})
|
|
81
111
|
}
|
|
82
112
|
}
|
|
@@ -96,7 +126,7 @@ export class Analytics {
|
|
|
96
126
|
|
|
97
127
|
constructor(config: AnalyticsConfig) {
|
|
98
128
|
this.cfg = {
|
|
99
|
-
host:
|
|
129
|
+
host: DEFAULT_HOST,
|
|
100
130
|
batchSize: 20,
|
|
101
131
|
flushIntervalMs: 5000,
|
|
102
132
|
enabled: true,
|
|
@@ -107,8 +137,9 @@ export class Analytics {
|
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
/** init is idempotent and browser-only for its side effects: capture first-touch
|
|
110
|
-
* attribution, hydrate cohort,
|
|
111
|
-
* a React effect on every
|
|
140
|
+
* attribution, hydrate cohort, register the unload flush, and (unless opted out)
|
|
141
|
+
* auto-capture unhandled errors. Safe to call from a React effect on every
|
|
142
|
+
* render. */
|
|
112
143
|
init(): void {
|
|
113
144
|
if (this.started || !this.cfg.enabled) return
|
|
114
145
|
this.started = true
|
|
@@ -130,7 +161,8 @@ export class Analytics {
|
|
|
130
161
|
window.addEventListener('pagehide', () => this.flush(true))
|
|
131
162
|
|
|
132
163
|
// Auto error capture — the drop-in @sentry replacement. Unhandled errors and
|
|
133
|
-
// rejected promises become type:'error' events on the same stream
|
|
164
|
+
// rejected promises become type:'error' events on the same stream, which Cloud
|
|
165
|
+
// stamps event_type='error' → the sentry.hanzo.ai lens.
|
|
134
166
|
if (this.cfg.captureErrors) {
|
|
135
167
|
window.addEventListener('error', (e: ErrorEvent) => {
|
|
136
168
|
this.captureError(e.error ?? e.message, { handled: false })
|
|
@@ -174,11 +206,12 @@ export class Analytics {
|
|
|
174
206
|
track = this.capture.bind(this)
|
|
175
207
|
|
|
176
208
|
/** captureError records an exception as a first-class error event — the ONE
|
|
177
|
-
* error path (subsumes @sentry). A caught error, an unhandled rejection,
|
|
178
|
-
* manual report all become a type:'error' event on the
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* the
|
|
209
|
+
* error path (subsumes @sentry). A caught error, an unhandled rejection, a
|
|
210
|
+
* React render error, or a manual report all become a type:'error' event on the
|
|
211
|
+
* same stream; Cloud folds the exception into properties.$exception and stamps
|
|
212
|
+
* event_type='error', so it surfaces in the error-tracking lens. Never throws
|
|
213
|
+
* back into the app; errors are higher-signal than pageviews, so it flushes
|
|
214
|
+
* promptly (a crash may unload the page moments later). */
|
|
182
215
|
captureError(
|
|
183
216
|
err: unknown,
|
|
184
217
|
context?: { handled?: boolean; properties?: Record<string, unknown> },
|
|
@@ -198,21 +231,32 @@ export class Analytics {
|
|
|
198
231
|
this.cohort = mergeCohort(patch)
|
|
199
232
|
}
|
|
200
233
|
|
|
201
|
-
/** flush drains the buffer to the server as
|
|
202
|
-
*
|
|
234
|
+
/** flush drains the buffer to the server as ONE batch through the ONE ingest
|
|
235
|
+
* front door POST /v1/event, body { batch: [Event…] }. beacon=true selects the
|
|
236
|
+
* unload-safe transport. Auth is orthogonal to the wire:
|
|
237
|
+
*
|
|
238
|
+
* • publishable key set → rides Authorization: Bearer pk_… (fetch) or
|
|
239
|
+
* ?ingest_key=pk_… (beacon), so unload beacons work anonymously.
|
|
240
|
+
* • else a bearer JWT rides Authorization (fetch only — sendBeacon cannot
|
|
241
|
+
* carry a header, so token apps fall back to keepalive fetch on unload).
|
|
242
|
+
* • else a cookie app rides same-origin credentials (beacon carries the
|
|
243
|
+
* cookie fine).
|
|
244
|
+
*/
|
|
203
245
|
flush(beacon = false): void {
|
|
204
246
|
if (!this.cfg.enabled || this.queue.length === 0) return
|
|
205
247
|
const batch = this.queue
|
|
206
248
|
this.queue = []
|
|
207
249
|
this.clearTimer()
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
250
|
+
|
|
251
|
+
const key = this.cfg.ingestKey?.trim() || undefined
|
|
252
|
+
// A publishable key and a bearer JWT are mutually exclusive doors; the key wins.
|
|
253
|
+
const token = key ? undefined : this.cfg.getToken?.() ?? undefined
|
|
254
|
+
// Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
|
|
255
|
+
// Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
|
|
211
256
|
const useBeacon = beacon && !token
|
|
212
|
-
const path = useBeacon ? TRACKER_PATH : ANALYTICS_PATH
|
|
213
257
|
const body = JSON.stringify({ batch })
|
|
214
|
-
if (this.cfg.debug) console.debug('[
|
|
215
|
-
this.transport.send(this.cfg.host +
|
|
258
|
+
if (this.cfg.debug) console.debug('[event] flush →', EVENT_PATH, batch.length)
|
|
259
|
+
this.transport.send(this.cfg.host + EVENT_PATH, body, { beacon: useBeacon, token, ingestKey: key })
|
|
216
260
|
}
|
|
217
261
|
|
|
218
262
|
// ── internals ────────────────────────────────────────────────────────────
|
package/src/types.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Public types for the Hanzo Event client.
|
|
2
2
|
|
|
3
3
|
/** The event kinds — the closed set the server understands. An error is just
|
|
4
|
-
* another event on the one stream (
|
|
4
|
+
* another event on the one stream (Cloud stamps type:'error' → event_type='error',
|
|
5
|
+
* the key the error-tracking lens filters on). */
|
|
5
6
|
export type EventKind = 'pageview' | 'event' | 'identify' | 'group' | 'error'
|
|
6
7
|
|
|
7
|
-
/** A captured exception. Carried on a `type:'error'` event
|
|
8
|
-
* into the
|
|
8
|
+
/** A captured exception. Carried on a `type:'error'` event's top-level `error`
|
|
9
|
+
* field; Cloud folds it into properties.$exception and lenses the event into the
|
|
10
|
+
* error-tracking view (sentry.hanzo.ai). */
|
|
9
11
|
export interface Exception {
|
|
10
12
|
/** Constructor/class name, e.g. "TypeError". */
|
|
11
13
|
type?: string
|
|
@@ -41,8 +43,10 @@ export interface Cohort {
|
|
|
41
43
|
refCode?: string
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/** One event as sent on the wire
|
|
45
|
-
*
|
|
46
|
+
/** One event as sent on the wire — the canonical Hanzo Cloud event. Maps 1:1 to
|
|
47
|
+
* the cloud `CaptureEvent` (camelCase JSON keys); a batch of these is POSTed to
|
|
48
|
+
* the ONE front door `/v1/event` as `{ batch: [WireEvent, …] }`. tenant/org is
|
|
49
|
+
* NEVER a field here — the server stamps it from the validated session/key. */
|
|
46
50
|
export interface WireEvent {
|
|
47
51
|
messageId: string
|
|
48
52
|
type: EventKind
|
|
@@ -65,28 +69,41 @@ export interface WireEvent {
|
|
|
65
69
|
quantity?: number
|
|
66
70
|
revenue?: number
|
|
67
71
|
currency?: string
|
|
68
|
-
/** Set on `type:'error'` events — the captured exception.
|
|
72
|
+
/** Set on `type:'error'` events — the captured exception. Cloud lifts it into
|
|
73
|
+
* properties.$exception (foldException) for the error-tracking lens. */
|
|
69
74
|
error?: Exception
|
|
70
75
|
properties?: Record<string, unknown>
|
|
71
76
|
library?: string
|
|
72
77
|
libraryVersion?: string
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
/** Injectable transports — overridden in tests; default in core.ts uses fetch
|
|
80
|
+
/** Injectable transports — overridden in tests; the default in core.ts uses fetch
|
|
81
|
+
* (keepalive) and sendBeacon. A bearer JWT or a publishable pk_ key rides
|
|
82
|
+
* Authorization on fetch; on a headerless beacon a publishable key rides the
|
|
83
|
+
* ?ingest_key query. */
|
|
76
84
|
export interface Transport {
|
|
77
85
|
/** Durable POST usable during page unload (fetch keepalive / sendBeacon). */
|
|
78
|
-
send(url: string, body: string, opts: { beacon: boolean; token?: string }): void
|
|
86
|
+
send(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }): void
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export interface AnalyticsConfig {
|
|
82
|
-
/** Cloud base URL.
|
|
83
|
-
*
|
|
90
|
+
/** Cloud base URL. Defaults to "https://api.hanzo.ai" (the one edge). Set to
|
|
91
|
+
* same-origin ("") for cookie-auth apps served behind the same edge
|
|
92
|
+
* (console/admin/chat), so the browser rides the session cookie. */
|
|
84
93
|
host?: string
|
|
85
94
|
/** Emitting surface: console | chat | app | site | admin. */
|
|
86
95
|
product: string
|
|
87
96
|
/** Bearer token provider for token-auth apps. Omit for cookie/session apps
|
|
88
97
|
* (the client then relies on same-origin credentials). */
|
|
89
98
|
getToken?: () => string | undefined | null
|
|
99
|
+
/** Publishable ingest key (pk_…). When set, the client authenticates to the ONE
|
|
100
|
+
* front door `/v1/event` with this key instead of a bearer/cookie: it rides
|
|
101
|
+
* Authorization: Bearer pk_… on fetch and ?ingest_key=pk_… on a headerless
|
|
102
|
+
* page-unload beacon, so ALL THREE lenses (web + product + error) light up with
|
|
103
|
+
* no bearer and unload beacons work anonymously. The key is write-only (cannot
|
|
104
|
+
* read) and safe to ship in a bundle; mint one per org via POST /v1/ingest/keys.
|
|
105
|
+
* Recommended for marketing/public pages and the full sentry-subsuming setup. */
|
|
106
|
+
ingestKey?: string
|
|
90
107
|
/** Max events buffered before an automatic flush. */
|
|
91
108
|
batchSize?: number
|
|
92
109
|
/** Auto-flush cadence in ms. */
|
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.
|