@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.22",
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.",
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.0"
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
- navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }))
186
- return
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> = { 'Content-Type': contentType }
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
- // The ONE funnel registry. A funnel is DATA — an ordered list of event names —
2
- // so the products, the Insights UI, and the docs can never drift: every step is
3
- // a name from EVENTS (funnels.test.ts fails the build otherwise), and a GOAL that
4
- // has a funnel points AT one of these rather than restating the steps.
5
- //
6
- // Why here and not in Insights: the taxonomy is code that all three surfaces
7
- // already import (`@hanzo/event`). A funnel defined in the read lens could name
8
- // an event no surface emits; a funnel defined next to EVENTS cannot.
9
-
10
- import { EVENTS, PAGEVIEW } from './events'
11
-
12
- /** The emitting surfaces — the closed set of `AnalyticsConfig.product` values.
13
- * `product` is on every event, so a funnel scopes by product instead of every
14
- * surface prefixing its event names. */
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({ via: 'beacon', url, headers: {}, batch: JSON.parse(blob.body).batch })
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
- constructor(parts: string[]) {
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({ via: 'fetch', url, headers: init.headers, batch: JSON.parse(init.body).batch })
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` defaults to application/json; the error plane overrides it with
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.21'
4
+ export const VERSION = '0.3.24'
@@ -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
- })