@hanzo/event 0.3.22 → 0.3.24
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/{core-CHLpJy51.d.cts → core-DO8tBUy7.d.cts} +4 -2
- package/dist/{core-CHLpJy51.d.ts → core-DO8tBUy7.d.ts} +4 -2
- package/dist/index.cjs +90 -177
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -95
- package/dist/index.d.ts +6 -95
- package/dist/index.mjs +77 -173
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +11 -5
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +11 -5
- package/dist/react.mjs.map +1 -1
- package/hz.js +7 -2
- package/package.json +11 -11
- package/src/core.ts +25 -5
- package/src/funnels.ts +14 -154
- package/src/goals.test.ts +33 -0
- package/src/hz.test.ts +45 -5
- package/src/transport.test.ts +125 -0
- package/src/types.ts +4 -2
- package/src/version.ts +1 -1
- package/src/funnels.test.ts +0 -92
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.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.",
|
|
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": {
|
|
@@ -83,6 +76,13 @@
|
|
|
83
76
|
"vitest": "^4.1.0"
|
|
84
77
|
},
|
|
85
78
|
"dependencies": {
|
|
86
|
-
"@hanzo/events": "^0.2.
|
|
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
|
@@ -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
|
-
|
|
186
|
-
|
|
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> = {
|
|
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/funnels.ts
CHANGED
|
@@ -1,154 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export const PRODUCTS = ['site', 'app', 'chat', 'console', 'admin', 'cloud'] as const
|
|
16
|
-
export type ProductId = (typeof PRODUCTS)[number]
|
|
17
|
-
|
|
18
|
-
export interface FunnelStep {
|
|
19
|
-
/** An EVENTS value (or PAGEVIEW). */
|
|
20
|
-
event: string
|
|
21
|
-
/** Human label for the Insights step. */
|
|
22
|
-
label: string
|
|
23
|
-
/** Property equality that qualifies the step, e.g. first_action{action:'api_call'}. */
|
|
24
|
-
where?: { property: string; equals: string }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface FunnelDef {
|
|
28
|
-
label: string
|
|
29
|
-
/** Surface(s) the steps are emitted from — matched against the `product` field. */
|
|
30
|
-
products: ProductId[]
|
|
31
|
-
/**
|
|
32
|
-
* How steps are joined:
|
|
33
|
-
* • 'person' — steps join on distinctId (one browser, or one logged-in
|
|
34
|
-
* person across surfaces). The normal case.
|
|
35
|
-
* • 'aggregate' — steps are emitted on DIFFERENT origins by a LOGGED-OUT
|
|
36
|
-
* visitor, so there is no shared id: hanzo.ai, hanzo.app and
|
|
37
|
-
* hanzo.chat each mint their own anonymousId in their own
|
|
38
|
-
* storage. Read these as step-over-step COUNTS, never as a
|
|
39
|
-
* per-person conversion. Honest by construction.
|
|
40
|
-
*/
|
|
41
|
-
join: 'person' | 'aggregate'
|
|
42
|
-
steps: FunnelStep[]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const s = (event: string, label: string, where?: FunnelStep['where']): FunnelStep => ({
|
|
46
|
-
event,
|
|
47
|
-
label,
|
|
48
|
-
...(where ? { where } : {}),
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
export const FUNNELS = {
|
|
52
|
-
/** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
|
|
53
|
-
* redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
|
|
54
|
-
signup: {
|
|
55
|
-
label: 'Signup',
|
|
56
|
-
products: ['site'],
|
|
57
|
-
join: 'person',
|
|
58
|
-
steps: [
|
|
59
|
-
s(PAGEVIEW, 'Landed'),
|
|
60
|
-
s(EVENTS.SIGNUP_VIEWED, 'Opened signup'),
|
|
61
|
-
s(EVENTS.SIGNUP_SUBMITTED, 'Redirected to Hanzo ID'),
|
|
62
|
-
s(EVENTS.SIGNUP_COMPLETED, 'Account created'),
|
|
63
|
-
s(EVENTS.FIRST_ACTION, 'First action'),
|
|
64
|
-
],
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
/** The developer activation path: an account is worth nothing until a key has
|
|
68
|
-
* made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
|
|
69
|
-
* Cloud on an org's first successful /v1 request — a browser cannot see it. */
|
|
70
|
-
apiActivation: {
|
|
71
|
-
label: 'API activation',
|
|
72
|
-
products: ['site', 'cloud'],
|
|
73
|
-
join: 'person',
|
|
74
|
-
steps: [
|
|
75
|
-
s(EVENTS.SIGNUP_COMPLETED, 'Account created'),
|
|
76
|
-
s(EVENTS.API_KEY_CREATED, 'Key minted'),
|
|
77
|
-
s(EVENTS.FIRST_ACTION, 'First successful API call', {
|
|
78
|
-
property: 'action',
|
|
79
|
-
equals: 'api_call',
|
|
80
|
-
}),
|
|
81
|
-
],
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
/** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
|
|
85
|
-
upgrade: {
|
|
86
|
-
label: 'Upgrade',
|
|
87
|
-
products: ['site', 'app', 'console'],
|
|
88
|
-
join: 'person',
|
|
89
|
-
steps: [
|
|
90
|
-
s(EVENTS.PRICING_VIEWED, 'Viewed pricing'),
|
|
91
|
-
s(EVENTS.PLAN_CLICKED, 'Chose a plan'),
|
|
92
|
-
s(EVENTS.CHECKOUT_STARTED, 'Started checkout'),
|
|
93
|
-
s(EVENTS.ORDER_COMPLETED, 'Paid'),
|
|
94
|
-
],
|
|
95
|
-
},
|
|
96
|
-
|
|
97
|
-
/** hanzo.app: describe → build → deploy → live URL. The whole product thesis
|
|
98
|
-
* in five steps; `deploy_succeeded` is the moment a live URL exists. */
|
|
99
|
-
appShip: {
|
|
100
|
-
label: 'Describe → ship',
|
|
101
|
-
products: ['app'],
|
|
102
|
-
join: 'person',
|
|
103
|
-
steps: [
|
|
104
|
-
s(PAGEVIEW, 'Landed'),
|
|
105
|
-
s(EVENTS.BUILD_STARTED, 'Described an app'),
|
|
106
|
-
s(EVENTS.GENERATION_COMPLETED, 'Got a working build'),
|
|
107
|
-
s(EVENTS.DEPLOY_STARTED, 'Hit publish'),
|
|
108
|
-
s(EVENTS.DEPLOY_SUCCEEDED, 'Live URL'),
|
|
109
|
-
],
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
/** hanzo.chat: visit → first message → answer. `generation_completed` is what
|
|
113
|
-
* separates "typed something" from "got value". */
|
|
114
|
-
chatEngage: {
|
|
115
|
-
label: 'Chat engagement',
|
|
116
|
-
products: ['chat'],
|
|
117
|
-
join: 'person',
|
|
118
|
-
steps: [
|
|
119
|
-
s(PAGEVIEW, 'Landed'),
|
|
120
|
-
s(EVENTS.CHAT_STARTED, 'Started a conversation'),
|
|
121
|
-
s(EVENTS.CHAT_MESSAGE_SENT, 'Sent a message'),
|
|
122
|
-
s(EVENTS.GENERATION_COMPLETED, 'Got an answer'),
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
/** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
|
|
127
|
-
* hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
|
|
128
|
-
* The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
|
|
129
|
-
* which makes the drop-off measurable without any cross-domain identity. */
|
|
130
|
-
siteToChat: {
|
|
131
|
-
label: 'Site → Chat handoff',
|
|
132
|
-
products: ['site', 'chat'],
|
|
133
|
-
join: 'aggregate',
|
|
134
|
-
steps: [
|
|
135
|
-
s(EVENTS.CHAT_STARTED, 'Submitted the hanzo.ai composer', {
|
|
136
|
-
property: 'source',
|
|
137
|
-
equals: 'composer',
|
|
138
|
-
}),
|
|
139
|
-
s(EVENTS.CHAT_STARTED, 'Landed in hanzo.chat', {
|
|
140
|
-
property: 'referrerProduct',
|
|
141
|
-
equals: 'site',
|
|
142
|
-
}),
|
|
143
|
-
s(EVENTS.GENERATION_COMPLETED, 'Got an answer'),
|
|
144
|
-
],
|
|
145
|
-
},
|
|
146
|
-
} as const satisfies Record<string, FunnelDef>
|
|
147
|
-
|
|
148
|
-
export type FunnelId = keyof typeof FUNNELS
|
|
149
|
-
|
|
150
|
-
/** eventsOf flattens a funnel to its ordered event names — what a goal's `funnel`
|
|
151
|
-
* field carries, so the steps are defined exactly once (here). */
|
|
152
|
-
export function eventsOf(id: FunnelId): string[] {
|
|
153
|
-
return FUNNELS[id].steps.map((step) => step.event)
|
|
154
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The funnel registry, re-exported from where it is now defined.
|
|
3
|
+
*
|
|
4
|
+
* The funnels moved to @hanzo/events for the reason the names did: the read lens
|
|
5
|
+
* is Go and cannot import TypeScript, and that package already writes its data
|
|
6
|
+
* out as JSON. Defining a funnel beside the vocabulary also makes the anti-drift
|
|
7
|
+
* check local — a step naming an event that does not exist is caught where both
|
|
8
|
+
* are declared.
|
|
9
|
+
*
|
|
10
|
+
* Nothing about the API moved. Every existing import keeps working.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { FUNNELS, PRODUCTS, eventsOf } from '@hanzo/events'
|
|
14
|
+
export type { FunnelDef, FunnelStep, FunnelId, ProductId } from '@hanzo/events'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A goal points AT a funnel and never restates its steps.
|
|
3
|
+
*
|
|
4
|
+
* The steps are defined once, in @hanzo/events. A goal that spelled its own copy
|
|
5
|
+
* would be right on the day it was written and wrong the day a step moved — and
|
|
6
|
+
* the way that surfaces is a conversion rate quietly measuring the wrong thing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import { EVENTS } from './events'
|
|
11
|
+
import { eventsOf } from './funnels'
|
|
12
|
+
import { GOALS } from './goals'
|
|
13
|
+
|
|
14
|
+
describe('goals', () => {
|
|
15
|
+
it('derive their funnel from the registry — one definition, never restated', () => {
|
|
16
|
+
for (const goal of Object.values(GOALS)) {
|
|
17
|
+
if (!goal.funnelId) continue
|
|
18
|
+
expect(goal.funnel).toEqual(eventsOf(goal.funnelId))
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('convert on an event the funnel actually contains', () => {
|
|
23
|
+
expect(GOALS.signup.funnel).toContain(EVENTS.SIGNUP_COMPLETED)
|
|
24
|
+
expect(GOALS.sale.funnel).toContain(EVENTS.ORDER_COMPLETED)
|
|
25
|
+
expect(GOALS.activation.funnel).toContain(EVENTS.FIRST_ACTION)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('does not gate signup on an event nothing emits', () => {
|
|
29
|
+
// signup_verified is IAM-internal: no surface emits it, so its presence in
|
|
30
|
+
// the signup funnel would pin the conversion rate at 0.
|
|
31
|
+
expect(GOALS.signup.funnel).not.toContain(EVENTS.SIGNUP_VERIFIED)
|
|
32
|
+
})
|
|
33
|
+
})
|
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>
|
|
@@ -106,8 +119,14 @@ function runSnippet(opts: StubOptions = {}): {
|
|
|
106
119
|
doNotTrack: '0',
|
|
107
120
|
...opts.navigator,
|
|
108
121
|
sendBeacon: opts.beacon
|
|
109
|
-
? (url: string, blob: { body: string }) => {
|
|
110
|
-
posts.push({
|
|
122
|
+
? (url: string, blob: { body: string; type: string }) => {
|
|
123
|
+
posts.push({
|
|
124
|
+
via: 'beacon',
|
|
125
|
+
url,
|
|
126
|
+
headers: {},
|
|
127
|
+
contentType: blob.type,
|
|
128
|
+
batch: JSON.parse(blob.body).batch,
|
|
129
|
+
})
|
|
111
130
|
return true
|
|
112
131
|
}
|
|
113
132
|
: undefined,
|
|
@@ -116,8 +135,10 @@ function runSnippet(opts: StubOptions = {}): {
|
|
|
116
135
|
'Blob',
|
|
117
136
|
class {
|
|
118
137
|
body: string
|
|
119
|
-
|
|
138
|
+
type: string
|
|
139
|
+
constructor(parts: string[], opts?: { type?: string }) {
|
|
120
140
|
this.body = parts.join('')
|
|
141
|
+
this.type = opts?.type ?? ''
|
|
121
142
|
}
|
|
122
143
|
},
|
|
123
144
|
)
|
|
@@ -133,7 +154,13 @@ function runSnippet(opts: StubOptions = {}): {
|
|
|
133
154
|
// "the previous run's API answered".
|
|
134
155
|
define('hanzo', undefined)
|
|
135
156
|
define('fetch', (url: string, init: { body: string; headers: Record<string, string> }) => {
|
|
136
|
-
posts.push({
|
|
157
|
+
posts.push({
|
|
158
|
+
via: 'fetch',
|
|
159
|
+
url,
|
|
160
|
+
headers: init.headers,
|
|
161
|
+
contentType: init.headers['content-type'] ?? '',
|
|
162
|
+
batch: JSON.parse(init.body).batch,
|
|
163
|
+
})
|
|
137
164
|
return Promise.resolve()
|
|
138
165
|
})
|
|
139
166
|
define('window', g)
|
|
@@ -247,6 +274,19 @@ describe('hz.js', () => {
|
|
|
247
274
|
expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
248
275
|
})
|
|
249
276
|
|
|
277
|
+
it('labels the beacon body a CORS-simple type, so the unload POST is sent', () => {
|
|
278
|
+
// The tag is installed on a customer's own domain, so every send here is
|
|
279
|
+
// cross-origin. A non-safelisted body type preflights, and an unloading
|
|
280
|
+
// document gets no second round trip — the batch would simply never leave.
|
|
281
|
+
// The credential is in the query for the same reason: a beacon sets no
|
|
282
|
+
// headers, and an Authorization header preflights whatever the type is.
|
|
283
|
+
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
|
|
284
|
+
r.api!.flush()
|
|
285
|
+
const post = r.posts.at(-1)!
|
|
286
|
+
expect(post.via).toBe('beacon')
|
|
287
|
+
expect(CORS_SAFELISTED.has(post.contentType)).toBe(true)
|
|
288
|
+
})
|
|
289
|
+
|
|
250
290
|
it('sends no credential when no key is declared — no baked literal', () => {
|
|
251
291
|
// The key is the surface's own, stamped into the tag by its deploy from KMS;
|
|
252
292
|
// a bare tag carries nothing, so it is honestly keyless rather than borrowing
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
// The three CORS-safelisted request content types. A POST whose body carries one
|
|
28
|
+
// of these is a SIMPLE request and is sent immediately; anything else is
|
|
29
|
+
// PREFLIGHTED, and an unloading document never gets the preflight's second round
|
|
30
|
+
// trip — so cross-origin, a non-safelisted beacon is not delayed, it is lost.
|
|
31
|
+
const CORS_SAFELISTED = new Set([
|
|
32
|
+
'text/plain',
|
|
33
|
+
'application/x-www-form-urlencoded',
|
|
34
|
+
'multipart/form-data',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
/** Runs ONE unload flush of the real DefaultTransport against a stub browser.
|
|
38
|
+
* `queued` is what navigator.sendBeacon answers — true when the agent accepts the
|
|
39
|
+
* batch, false when it refuses (a body past the beacon size limit, or a full
|
|
40
|
+
* queue). Globals are DEFINED and restored: Node ships a real `navigator` whose
|
|
41
|
+
* descriptor has no setter, so a plain assignment throws. */
|
|
42
|
+
function unloadFlush(queued: boolean): { beacons: Beaconed[]; fetches: Fetched[] } {
|
|
43
|
+
const beacons: Beaconed[] = []
|
|
44
|
+
const fetches: Fetched[] = []
|
|
45
|
+
const g = globalThis as Record<string, unknown>
|
|
46
|
+
const names = ['window', 'document', 'navigator', 'Blob', 'fetch']
|
|
47
|
+
const saved = names.map((n) => [n, Object.getOwnPropertyDescriptor(g, n)] as const)
|
|
48
|
+
const define = (name: string, value: unknown) =>
|
|
49
|
+
Object.defineProperty(g, name, { value, configurable: true, writable: true })
|
|
50
|
+
|
|
51
|
+
define('window', {
|
|
52
|
+
location: { href: 'https://acme.test/checkout', pathname: '/checkout', search: '' },
|
|
53
|
+
addEventListener: () => {},
|
|
54
|
+
})
|
|
55
|
+
define('document', { referrer: '', visibilityState: 'visible' })
|
|
56
|
+
define('navigator', {
|
|
57
|
+
sendBeacon: (url: string, blob: { type: string; body: string }) => {
|
|
58
|
+
beacons.push({ url, type: blob.type, body: blob.body })
|
|
59
|
+
return queued
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
define(
|
|
63
|
+
'Blob',
|
|
64
|
+
class {
|
|
65
|
+
type: string
|
|
66
|
+
body: string
|
|
67
|
+
constructor(parts: string[], opts?: { type?: string }) {
|
|
68
|
+
this.body = parts.join('')
|
|
69
|
+
this.type = opts?.type ?? ''
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
)
|
|
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
|
+
})
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// No `transport`, so the client builds the real DefaultTransport. The key is
|
|
80
|
+
// explicit rather than inherited from the build env, so the assertions below
|
|
81
|
+
// are about this batch and not about the machine running them.
|
|
82
|
+
const a = new Analytics({ product: 'test', ingestKey: 'pk-abc123', captureErrors: false })
|
|
83
|
+
a.capture('checkout_started')
|
|
84
|
+
a.flush(true)
|
|
85
|
+
} finally {
|
|
86
|
+
for (const [n, d] of saved) {
|
|
87
|
+
if (d) Object.defineProperty(g, n, d)
|
|
88
|
+
else delete g[n]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { beacons, fetches }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('the unload beacon', () => {
|
|
95
|
+
it('is a CORS-simple request, so an unloading document actually sends it', () => {
|
|
96
|
+
const { beacons, fetches } = unloadFlush(true)
|
|
97
|
+
expect(beacons).toHaveLength(1)
|
|
98
|
+
|
|
99
|
+
// The load-bearing assertion. A non-safelisted type preflights, and there is
|
|
100
|
+
// no second round trip during unload.
|
|
101
|
+
expect(CORS_SAFELISTED.has(beacons[0].type)).toBe(true)
|
|
102
|
+
|
|
103
|
+
// The other half of what makes it simple: a beacon can set no headers, so the
|
|
104
|
+
// credential rides the query. An Authorization header would preflight whatever
|
|
105
|
+
// the body's type is.
|
|
106
|
+
expect(beacons[0].url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
107
|
+
|
|
108
|
+
// A queued batch is sent ONCE. Falling through here would double-count every
|
|
109
|
+
// unload event in the warehouse.
|
|
110
|
+
expect(fetches).toHaveLength(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('falls through to the keepalive fetch when the agent refuses to queue it', () => {
|
|
114
|
+
const { beacons, fetches } = unloadFlush(false)
|
|
115
|
+
|
|
116
|
+
// Offered to the beacon first...
|
|
117
|
+
expect(beacons).toHaveLength(1)
|
|
118
|
+
|
|
119
|
+
// ...and, refused, carried by the fetch sitting behind it rather than dropped.
|
|
120
|
+
expect(fetches).toHaveLength(1)
|
|
121
|
+
const batch = (JSON.parse(fetches[0].body) as { batch: { event?: string }[] }).batch
|
|
122
|
+
expect(batch.map((e) => e.event)).toContain('checkout_started')
|
|
123
|
+
expect(fetches[0].headers.Authorization).toBe('Bearer pk-abc123')
|
|
124
|
+
})
|
|
125
|
+
})
|
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`
|
|
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.
|
|
4
|
+
export const VERSION = '0.3.24'
|
package/src/funnels.test.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { EVENTS, PAGEVIEW } from './events'
|
|
3
|
-
import { FUNNELS, PRODUCTS, eventsOf, type FunnelId } from './funnels'
|
|
4
|
-
import { GOALS } from './goals'
|
|
5
|
-
|
|
6
|
-
const NAMES = new Set<string>([...Object.values(EVENTS), PAGEVIEW])
|
|
7
|
-
const ids = Object.keys(FUNNELS) as FunnelId[]
|
|
8
|
-
|
|
9
|
-
describe('event vocabulary', () => {
|
|
10
|
-
it('is snake_case with no product prefix or interpolation', () => {
|
|
11
|
-
for (const name of Object.values(EVENTS)) {
|
|
12
|
-
expect(name, name).toMatch(/^[a-z][a-z0-9]*(_[a-z0-9]+)+$/)
|
|
13
|
-
}
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('has no duplicate values (two constants must never share a name)', () => {
|
|
17
|
-
const values = Object.values(EVENTS)
|
|
18
|
-
expect(new Set(values).size).toBe(values.length)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('reserves the $ prefix for the client (only $pageview)', () => {
|
|
22
|
-
expect(Object.values(EVENTS).some((n) => n.startsWith('$'))).toBe(false)
|
|
23
|
-
expect(PAGEVIEW).toBe('$pageview')
|
|
24
|
-
})
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('funnels', () => {
|
|
28
|
-
it('only reference events that exist — the anti-drift guard', () => {
|
|
29
|
-
for (const id of ids) {
|
|
30
|
-
for (const step of FUNNELS[id].steps) {
|
|
31
|
-
expect(NAMES.has(step.event), `${id}: unknown event "${step.event}"`).toBe(true)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('name only known products', () => {
|
|
37
|
-
for (const id of ids) {
|
|
38
|
-
for (const p of FUNNELS[id].products) {
|
|
39
|
-
expect(PRODUCTS, `${id}: unknown product "${p}"`).toContain(p)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('are ordered lists of at least two steps', () => {
|
|
45
|
-
for (const id of ids) expect(FUNNELS[id].steps.length, id).toBeGreaterThanOrEqual(2)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('mark a cross-origin funnel as aggregate (no shared anonymousId)', () => {
|
|
49
|
-
// Two surfaces + a logged-out visitor = two anonymousIds. Anything spanning
|
|
50
|
-
// origins must say so, or the read lens silently reports a false conversion.
|
|
51
|
-
for (const id of ids) {
|
|
52
|
-
if (FUNNELS[id].products.length > 1 && FUNNELS[id].join === 'person') {
|
|
53
|
-
// person-joined multi-product funnels are only legitimate post-login
|
|
54
|
-
// (site+cloud share the OIDC subject) — flag anything anonymous.
|
|
55
|
-
expect(FUNNELS[id].steps[0].event, id).not.toBe(PAGEVIEW)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
expect(FUNNELS.siteToChat.join).toBe('aggregate')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('the three product journeys are covered', () => {
|
|
62
|
-
expect(FUNNELS.signup.products).toContain('site')
|
|
63
|
-
expect(FUNNELS.appShip.products).toContain('app')
|
|
64
|
-
expect(FUNNELS.chatEngage.products).toContain('chat')
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('appShip ends at a live URL, not at a click', () => {
|
|
68
|
-
const last = FUNNELS.appShip.steps[FUNNELS.appShip.steps.length - 1]
|
|
69
|
-
expect(last.event).toBe(EVENTS.DEPLOY_SUCCEEDED)
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
describe('goals', () => {
|
|
74
|
-
it('derive their funnel from the registry — one definition, never restated', () => {
|
|
75
|
-
for (const goal of Object.values(GOALS)) {
|
|
76
|
-
if (!goal.funnelId) continue
|
|
77
|
-
expect(goal.funnel).toEqual(eventsOf(goal.funnelId))
|
|
78
|
-
}
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('convert on an event the funnel actually contains (or its head)', () => {
|
|
82
|
-
expect(GOALS.signup.funnel).toContain(EVENTS.SIGNUP_COMPLETED)
|
|
83
|
-
expect(GOALS.sale.funnel).toContain(EVENTS.ORDER_COMPLETED)
|
|
84
|
-
expect(GOALS.activation.funnel).toContain(EVENTS.FIRST_ACTION)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('no longer gates signup on an event nothing emits', () => {
|
|
88
|
-
// signup_verified is IAM-internal: no surface emits it, so its presence in
|
|
89
|
-
// the signup funnel pinned the conversion rate at 0.
|
|
90
|
-
expect(GOALS.signup.funnel).not.toContain(EVENTS.SIGNUP_VERIFIED)
|
|
91
|
-
})
|
|
92
|
-
})
|