@hanzo/event 0.3.1 → 0.3.3

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/src/core.ts CHANGED
@@ -1,13 +1,27 @@
1
- // The framework-agnostic event client. Buffers events and flushes them as ONE
2
- // batch through the ONE Hanzo Cloud ingestion front door:
1
+ // The framework-agnostic event client. ONE API surface over TWO orthogonal
2
+ // planes, sharing one session and one identity:
3
3
  //
4
- // POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
4
+ // 1. EVENT STREAM buffered pageview/event/identify/group, flushed as ONE
5
+ // batch to the Hanzo Cloud front door:
6
+ // POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
7
+ // Cloud resolves the tenant server-side (validated session, or the signed
8
+ // publishable key) and stamps it; the client NEVER sends the org.
5
9
  //
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).
10
+ // 2. ERROR PLANE every captured exception is ALSO framed as a real Sentry
11
+ // envelope and POSTed to the error host named by the DSN:
12
+ // POST {dsn.origin}/v1/sentry/{projectId}/envelope/?sentry_key=…
13
+ // This is what reaches sentry.hanzo.ai (issues, grouping, stack frames).
14
+ //
15
+ // These are NOT the same pipe and one does NOT feed the other. The event stream
16
+ // stores a `type:'error'` row in the cloud event warehouse (readable via
17
+ // GET /v1/errors) — that is product signal, not error tracking. There is no
18
+ // server-side fan-out from /v1/event into Sentry; without the envelope below,
19
+ // nothing ever reaches sentry.hanzo.ai. An earlier revision of this file claimed
20
+ // the one door was "lensed server-side into … error tracking (sentry)". It was
21
+ // wrong, and it silently cost the fleet all of its error telemetry.
22
+ //
23
+ // The error plane is inert (fail-safe) when no DSN is configured: nothing is
24
+ // sent, nothing throws, and the event stream is unaffected.
11
25
  //
12
26
  // Auth is orthogonal — the SAME body to the SAME door, differing only in how the
13
27
  // caller proves its tenant:
@@ -19,10 +33,10 @@
19
33
  // safe to ship in a bundle; the door HMAC-verifies it to an org server-side.
20
34
  //
21
35
  // 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.)
36
+ // is what Cloud folds to event_type='error', which is how the event WAREHOUSE
37
+ // classifies the row (GET /v1/errors). That is the extent of it — the fold does
38
+ // not forward anything to Sentry. The error dashboard is fed only by the envelope
39
+ // in plane 2 above, and only when a DSN is set.
26
40
 
27
41
  import {
28
42
  parseAttribution,
@@ -30,6 +44,13 @@ import {
30
44
  deriveChannel,
31
45
  } from './attribution'
32
46
  import { PAGEVIEW } from './events'
47
+ import {
48
+ buildEnvelope,
49
+ buildSentryEvent,
50
+ normalizeError as normalizeThrowable,
51
+ parseDsn,
52
+ type ErrorIdentity,
53
+ } from './sentry'
33
54
  import {
34
55
  anonId,
35
56
  sessionId,
@@ -41,17 +62,46 @@ import {
41
62
  import type {
42
63
  AnalyticsConfig,
43
64
  Attribution,
65
+ CaptureErrorOptions,
44
66
  Cohort,
67
+ Dsn,
45
68
  EventKind,
46
69
  Exception,
47
70
  Transport,
48
71
  WireEvent,
49
72
  } from './types'
73
+ import { VERSION } from './version'
50
74
 
51
- export const VERSION = '0.3.0'
75
+ export { VERSION }
52
76
 
53
77
  const EVENT_PATH = '/v1/event' // the ONE canonical ingestion front door
54
78
  const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
79
+ const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
80
+
81
+ /** readEnvDsn resolves a DSN from the public env when config omits one, so an app
82
+ * gets the error plane by setting ONE build-time variable and nothing else.
83
+ * Next/Vite inline these at build; the access is guarded so it is safe in a bare
84
+ * browser and during SSR/prerender where `process` may not exist. */
85
+ function readEnvDsn(): string | undefined {
86
+ try {
87
+ if (typeof process !== 'undefined' && process.env) {
88
+ return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || undefined
89
+ }
90
+ } catch {
91
+ /* no process — browser without inlined env */
92
+ }
93
+ return undefined
94
+ }
95
+
96
+ /** readEnv reads an inlined build-time variable, guarded like readEnvDsn. */
97
+ function readEnv(name: string): string | undefined {
98
+ try {
99
+ if (typeof process !== 'undefined' && process.env) return process.env[name] || undefined
100
+ } catch {
101
+ /* no process */
102
+ }
103
+ return undefined
104
+ }
55
105
 
56
106
  /** appendQuery adds a single query param to a URL string — used to carry a
57
107
  * publishable key on a headerless sendBeacon (?ingest_key=…). */
@@ -66,37 +116,75 @@ function uid(): string {
66
116
  }
67
117
 
68
118
  /** Normalize anything thrown (Error | string | unknown) into an Exception. */
119
+ /** normalizeError adapts the shared, hostile-input-safe normalizer (sentry.ts) to
120
+ * the event stream's Exception shape. ONE normalizer serves both planes: a thrown
121
+ * object may define `name`/`message`/`stack` as throwing getters, and when each
122
+ * plane rolled its own reader the stream still lost the report that the error
123
+ * plane had already survived. */
69
124
  function normalizeError(err: unknown): Exception {
70
- if (err instanceof Error) {
71
- return { type: err.name, message: err.message, stack: err.stack }
72
- }
73
- if (typeof err === 'string') return { message: err }
125
+ const n = normalizeThrowable(err)
126
+ return { type: n.name, message: n.message, stack: n.stack }
127
+ }
128
+
129
+ const isBrowser = () => typeof window !== 'undefined'
130
+
131
+ /** serializeBatch stringifies a batch, salvaging what it can. `properties` is
132
+ * arbitrary caller data — a DOM node, a React synthetic event, an axios error are
133
+ * all circular, and a getter on one can throw — so a whole batch must never be
134
+ * lost to a single poisoned event. Falls back to per-event serialization, then to
135
+ * keeping the event and dropping its properties. Returns null only when nothing
136
+ * at all survives. */
137
+ function serializeBatch(batch: WireEvent[]): string | null {
74
138
  try {
75
- return { message: JSON.stringify(err) }
139
+ return JSON.stringify({ batch })
76
140
  } catch {
77
- return { message: String(err) }
141
+ /* one bad event — salvage the rest below */
142
+ }
143
+ const parts: string[] = []
144
+ for (const e of batch) {
145
+ try {
146
+ parts.push(JSON.stringify(e))
147
+ } catch {
148
+ try {
149
+ // Keep the event (identity, session, type all matter); drop the payload
150
+ // that could not be serialized, and say so rather than lying by omission.
151
+ parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }))
152
+ } catch {
153
+ /* unsalvageable — drop this ONE event, never the batch */
154
+ }
155
+ }
78
156
  }
157
+ return parts.length > 0 ? '{"batch":[' + parts.join(',') + ']}' : null
79
158
  }
80
159
 
81
- const isBrowser = () => typeof window !== 'undefined'
82
-
83
160
  /** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
84
161
  * navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a
85
162
  * publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot
86
163
  * set headers — a publishable key rides the ?ingest_key query instead. */
87
164
  class DefaultTransport implements Transport {
88
- send(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }): void {
165
+ send(
166
+ url: string,
167
+ body: string,
168
+ opts: {
169
+ beacon: boolean
170
+ token?: string
171
+ ingestKey?: string
172
+ contentType?: string
173
+ debug?: boolean
174
+ },
175
+ ): void {
176
+ const contentType = opts.contentType ?? 'application/json'
89
177
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
90
178
  const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url
91
179
  try {
92
- navigator.sendBeacon(beaconUrl, new Blob([body], { type: 'application/json' }))
180
+ navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }))
93
181
  return
94
182
  } catch {
95
183
  /* fall through to fetch */
96
184
  }
97
185
  }
98
186
  if (typeof fetch !== 'function') return
99
- const headers: Record<string, string> = { 'Content-Type': 'application/json' }
187
+ const headers: Record<string, string> = { 'Content-Type': contentType }
100
188
  const bearer = opts.ingestKey ?? opts.token
101
189
  if (bearer) headers.Authorization = `Bearer ${bearer}`
102
190
  void fetch(url, {
@@ -105,9 +193,19 @@ class DefaultTransport implements Transport {
105
193
  body,
106
194
  keepalive: true,
107
195
  credentials: 'include',
108
- }).catch(() => {
109
- /* telemetry loss is acceptable; never throw into the app */
110
196
  })
197
+ .then((res) => {
198
+ // Telemetry loss never throws into the app — but silence is how this
199
+ // client lost the fleet's errors in the first place. Under `debug`, say
200
+ // so. A rejected ingest (the CORS allowlist 403s non-production origins,
201
+ // so local dev NEVER reports) is otherwise indistinguishable from success.
202
+ if (!res.ok && opts.debug) {
203
+ console.warn('[event] ingest rejected', res.status, url.split('?')[0])
204
+ }
205
+ })
206
+ .catch((e: unknown) => {
207
+ if (opts.debug) console.warn('[event] ingest failed', url.split('?')[0], e)
208
+ })
111
209
  }
112
210
  }
113
211
 
@@ -123,6 +221,10 @@ export class Analytics {
123
221
  private attribution: Attribution = { utm: {} }
124
222
  private cohort: Cohort = {}
125
223
  private started = false
224
+ /** Parsed error-plane DSN, or null when the plane is inert. */
225
+ private dsn: Dsn | null
226
+ /** Guards against an error thrown *inside* the error path re-entering it. */
227
+ private reentrant = false
126
228
 
127
229
  constructor(config: AnalyticsConfig) {
128
230
  this.cfg = {
@@ -134,6 +236,23 @@ export class Analytics {
134
236
  ...config,
135
237
  }
136
238
  this.transport = config.transport ?? new DefaultTransport()
239
+ // Error plane: explicit DSN wins, else the inlined build-time env. Malformed
240
+ // or absent => null => inert, never throwing into the host app.
241
+ this.dsn = parseDsn(config.dsn ?? readEnvDsn())
242
+ }
243
+
244
+ /** errorPlaneEnabled reports whether captured exceptions can actually reach the
245
+ * error host. False means a DSN was never configured — the documented
246
+ * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
247
+ * discovering months later that nothing was ever reported. */
248
+ get errorPlaneEnabled(): boolean {
249
+ return this.dsn !== null
250
+ }
251
+
252
+ /** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
253
+ * plane is inert. Diagnostics only. */
254
+ get errorIngestUrl(): string | undefined {
255
+ return this.dsn?.ingestUrl
137
256
  }
138
257
 
139
258
  /** init is idempotent and browser-only for its side effects: capture first-touch
@@ -161,8 +280,9 @@ export class Analytics {
161
280
  window.addEventListener('pagehide', () => this.flush(true))
162
281
 
163
282
  // Auto error capture — the drop-in @sentry replacement. Unhandled errors and
164
- // rejected promises become type:'error' events on the same stream, which Cloud
165
- // stamps event_type='error' the sentry.hanzo.ai lens.
283
+ // rejected promises are reported on BOTH planes: a Sentry envelope to the DSN
284
+ // host (what reaches the error dashboard — requires a DSN) and a type:'error'
285
+ // event on the stream (product signal in the warehouse).
166
286
  if (this.cfg.captureErrors) {
167
287
  window.addEventListener('error', (e: ErrorEvent) => {
168
288
  this.captureError(e.error ?? e.message, { handled: false })
@@ -205,21 +325,49 @@ export class Analytics {
205
325
  /** track is an alias of capture (Segment familiarity). */
206
326
  track = this.capture.bind(this)
207
327
 
208
- /** captureError records an exception as a first-class error event the ONE
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). */
215
- captureError(
216
- err: unknown,
217
- context?: { handled?: boolean; properties?: Record<string, unknown> },
218
- ): void {
219
- const ex = normalizeError(err)
220
- ex.handled = context?.handled ?? true
221
- this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
222
- this.flush()
328
+ /** captureError reports a caught error, an unhandled rejection, a React render
329
+ * error, or a manual report to BOTH planes, from one call:
330
+ *
331
+ * - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
332
+ * that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
333
+ * Inert when no DSN is configured.
334
+ * - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
335
+ * an error stays correlated with the session's pageviews for product
336
+ * analysis (readable via GET /v1/errors).
337
+ *
338
+ * Both carry the SAME session and subject id, so an error and the pageview
339
+ * before it join up. Never throws back into the app; errors are higher-signal
340
+ * than pageviews, so both planes flush promptly (a crash may unload the page
341
+ * moments later). */
342
+ captureError(err: unknown, context?: CaptureErrorOptions): void {
343
+ // A failure inside the error path must not recurse through the global handlers.
344
+ if (this.reentrant) return
345
+ this.reentrant = true
346
+ try {
347
+ // ERROR PLANE FIRST, in its own try. The planes are independent, so neither
348
+ // may be able to starve the other: `properties` is arbitrary caller data
349
+ // (a DOM node, a React synthetic event, an axios error — all circular and
350
+ // all common), and serializing it on the event stream can throw. When the
351
+ // stream ran first, that throw escaped to the outer catch and the crash
352
+ // report was never sent — silently losing exactly the signal this client
353
+ // exists to deliver. Order and isolation are the fix.
354
+ try {
355
+ this.sendError(err, context)
356
+ } catch {
357
+ /* the error plane must never take the event stream down with it */
358
+ }
359
+
360
+ try {
361
+ const ex = normalizeError(err)
362
+ ex.handled = context?.handled ?? true
363
+ this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
364
+ this.flush()
365
+ } catch {
366
+ /* nor the reverse */
367
+ }
368
+ } finally {
369
+ this.reentrant = false
370
+ }
223
371
  }
224
372
 
225
373
  /** captureException — @sentry-familiar alias of captureError. */
@@ -254,13 +402,57 @@ export class Analytics {
254
402
  // Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
255
403
  // Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
256
404
  const useBeacon = beacon && !token
257
- const body = JSON.stringify({ batch })
405
+ const body = serializeBatch(batch)
406
+ if (body === null) {
407
+ if (this.cfg.debug) console.debug('[event] flush → dropped, batch unserializable')
408
+ return
409
+ }
258
410
  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 })
411
+ this.transport.send(this.cfg.host + EVENT_PATH, body, {
412
+ beacon: useBeacon,
413
+ token,
414
+ ingestKey: key,
415
+ debug: this.cfg.debug,
416
+ })
260
417
  }
261
418
 
262
419
  // ── internals ────────────────────────────────────────────────────────────
263
420
 
421
+ /** sendError frames one exception as a Sentry envelope and posts it to the DSN's
422
+ * ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
423
+ * server trusts, and the only one a headerless beacon can carry), so NO bearer
424
+ * or publishable key is attached here — the two planes authenticate
425
+ * independently. Errors are sent one envelope per event, immediately: batching
426
+ * a crash report is how you lose it. */
427
+ private sendError(err: unknown, options?: CaptureErrorOptions): void {
428
+ if (!this.cfg.enabled || !this.dsn) return
429
+ const event = buildSentryEvent({
430
+ error: err,
431
+ options,
432
+ identity: this.errorIdentity(),
433
+ capturePII: this.cfg.capturePII ?? false,
434
+ })
435
+ const body = buildEnvelope(event, this.dsn)
436
+ if (this.cfg.debug) console.debug('[event] error →', this.dsn.ingestUrl, event.event_id)
437
+ this.transport.send(this.dsn.ingestUrl, body, {
438
+ beacon: false,
439
+ contentType: ENVELOPE_CONTENT_TYPE,
440
+ debug: this.cfg.debug,
441
+ })
442
+ }
443
+
444
+ /** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
445
+ * once identify() has run, else the anon id. Never email/PII. */
446
+ private errorIdentity(): ErrorIdentity {
447
+ return {
448
+ userId: this.personId ?? anonId(),
449
+ sessionId: sessionId(),
450
+ product: this.cfg.product,
451
+ release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
452
+ environment: this.cfg.environment ?? readEnv('NODE_ENV'),
453
+ }
454
+ }
455
+
264
456
  private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
265
457
  if (!this.cfg.enabled) return
266
458
  if (!this.started) this.init()
package/src/events.ts CHANGED
@@ -2,6 +2,14 @@
2
2
  // names so funnels, goals, and cohorts line up across console/chat/app/site/admin.
3
3
  // Pageviews use the reserved "$pageview" name (emitted by analytics.pageview()),
4
4
  // matching the server read lens.
5
+ //
6
+ // Naming is a closed convention, not a style preference (see TAXONOMY.md):
7
+ // • snake_case, `<object>_<verb-in-past-tense>` — signup_completed, plan_clicked.
8
+ // • Names are CONSTANTS, never interpolated. A dimension (framework, model,
9
+ // plan, source) is a PROPERTY, never part of the name. `deploy_succeeded`
10
+ // with {framework:'static'} — never `deploy_static_succeeded`.
11
+ // • One name per user-visible moment, shared by every surface. The surface is
12
+ // already on the wire as `product`, so a name is never product-prefixed.
5
13
 
6
14
  export const EVENTS = {
7
15
  // Signup funnel: view -> submit -> verify -> completed -> first action.
@@ -9,6 +17,12 @@ export const EVENTS = {
9
17
  SIGNUP_SUBMITTED: 'signup_submitted',
10
18
  SIGNUP_VERIFIED: 'signup_verified',
11
19
  SIGNUP_COMPLETED: 'signup_completed',
20
+ /** A RETURNING user authenticated — the non-signup half of the IAM callback.
21
+ * Keeping it distinct is what stops returning logins from inflating signups. */
22
+ LOGIN_COMPLETED: 'login_completed',
23
+ /** Activation: the first moment of real value. ONE event for every product —
24
+ * the product-specific moment is the `action` property (api_call, app_live,
25
+ * chat_reply), never a new event name. */
12
26
  FIRST_ACTION: 'first_action',
13
27
 
14
28
  // Waitlist + referral.
@@ -27,13 +41,28 @@ export const EVENTS = {
27
41
  FEATURE_USED: 'feature_used',
28
42
  API_KEY_CREATED: 'api_key_created',
29
43
  APP_CREATED: 'app_created',
30
- DEPLOY_STARTED: 'deploy_started',
31
44
  PROJECT_CREATED: 'project_created',
32
45
  AGENT_CREATED: 'agent_created',
33
46
  CHAT_STARTED: 'chat_started',
34
47
  CHAT_MESSAGE_SENT: 'chat_message_sent',
48
+ /** The user switched model/endpoint — the single strongest quality signal a
49
+ * chat surface emits (a switch usually follows a bad answer). */
50
+ MODEL_SWITCHED: 'model_switched',
35
51
  TASK_STARTED: 'task_started',
36
52
  TASK_COMPLETED: 'task_completed',
53
+
54
+ // Build → ship. `build_*` is a MODEL producing an artifact; `deploy_*` is that
55
+ // artifact going live. Intent (build_started) is never the same event as the
56
+ // artifact existing (app_created) — conflating them makes the funnel lie.
57
+ BUILD_STARTED: 'build_started',
58
+ /** A model finished producing an artifact (an app build, a chat reply, an agent
59
+ * run). Carries `durationMs` — the outcome event owns its own duration, so no
60
+ * paired start event is needed. */
61
+ GENERATION_COMPLETED: 'generation_completed',
62
+ GENERATION_FAILED: 'generation_failed',
63
+ DEPLOY_STARTED: 'deploy_started',
64
+ DEPLOY_SUCCEEDED: 'deploy_succeeded',
65
+ DEPLOY_FAILED: 'deploy_failed',
37
66
  } as const
38
67
 
39
68
  export type EventName = (typeof EVENTS)[keyof typeof EVENTS]
@@ -0,0 +1,92 @@
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
+ })