@hanzo/event 0.3.21 → 0.3.23
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/dist/index.cjs +100 -235
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -142
- package/dist/index.d.ts +4 -142
- package/dist/index.mjs +68 -220
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +4 -7
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +2 -5
- package/dist/react.mjs.map +1 -1
- package/hz.js +1 -1
- package/package.json +4 -1
- package/src/events.ts +21 -76
- package/src/funnels.ts +14 -154
- package/src/goals.test.ts +33 -0
- package/src/version.ts +1 -1
- package/src/funnels.test.ts +0 -92
package/hz.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
var LIB = 'hz.js'
|
|
58
|
-
var VERSION = '0.3.
|
|
58
|
+
var VERSION = '0.3.23'
|
|
59
59
|
var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
|
|
60
60
|
var product = s.getAttribute('data-product') || location.hostname
|
|
61
61
|
var capture = s.getAttribute('data-capture') !== '0'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"description": "Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -75,6 +75,9 @@
|
|
|
75
75
|
"typescript": "^5.9.3",
|
|
76
76
|
"vitest": "^4.1.0"
|
|
77
77
|
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"@hanzo/events": "^0.2.1"
|
|
80
|
+
},
|
|
78
81
|
"scripts": {
|
|
79
82
|
"build": "tsup",
|
|
80
83
|
"dev": "tsup --watch",
|
package/src/events.ts
CHANGED
|
@@ -1,76 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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. */
|
|
26
|
-
FIRST_ACTION: 'first_action',
|
|
27
|
-
|
|
28
|
-
// Waitlist + referral.
|
|
29
|
-
WAITLIST_JOINED: 'waitlist_joined',
|
|
30
|
-
WAITLIST_SHARED: 'waitlist_shared',
|
|
31
|
-
REFERRAL_USED: 'referral_used',
|
|
32
|
-
REFERRAL_CLAIMED: 'referral_claimed',
|
|
33
|
-
|
|
34
|
-
// Upgrade-intent + purchase.
|
|
35
|
-
PRICING_VIEWED: 'pricing_viewed',
|
|
36
|
-
PLAN_CLICKED: 'plan_clicked',
|
|
37
|
-
CHECKOUT_STARTED: 'checkout_started',
|
|
38
|
-
ORDER_COMPLETED: 'order_completed',
|
|
39
|
-
|
|
40
|
-
// Feature usage — generic + the common key surfaces across products.
|
|
41
|
-
FEATURE_USED: 'feature_used',
|
|
42
|
-
API_KEY_CREATED: 'api_key_created',
|
|
43
|
-
APP_CREATED: 'app_created',
|
|
44
|
-
PROJECT_CREATED: 'project_created',
|
|
45
|
-
AGENT_CREATED: 'agent_created',
|
|
46
|
-
CHAT_STARTED: 'chat_started',
|
|
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',
|
|
51
|
-
TASK_STARTED: 'task_started',
|
|
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',
|
|
66
|
-
} as const
|
|
67
|
-
|
|
68
|
-
export type EventName = (typeof EVENTS)[keyof typeof EVENTS]
|
|
69
|
-
|
|
70
|
-
/** The reserved event name a pageview is stored under (server + read lens). */
|
|
71
|
-
export const PAGEVIEW = '$pageview'
|
|
72
|
-
|
|
73
|
-
/** The reserved name every captured exception is emitted under. Error Tracking
|
|
74
|
-
* reads exactly this name; an exception emitted under its own message instead
|
|
75
|
-
* makes every distinct message a permanent entry in the event taxonomy. */
|
|
76
|
-
export const EXCEPTION = '$exception'
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary, re-exported from where it is now defined.
|
|
3
|
+
*
|
|
4
|
+
* These names used to be declared here. They moved to @hanzo/events — the
|
|
5
|
+
* plural package is the catalog (many events, what each means, what each
|
|
6
|
+
* carries), this one is the client (one call sends one event). Splitting them
|
|
7
|
+
* that way is what lets the ingest door read the same catalog: the door is Go
|
|
8
|
+
* and cannot import TypeScript, so @hanzo/events also ships the data as JSON,
|
|
9
|
+
* and there is still exactly one definition.
|
|
10
|
+
*
|
|
11
|
+
* Nothing about the API moved. Every existing import keeps working, the names
|
|
12
|
+
* and their values are unchanged, and `pkgs/events/test/catalog.mjs` fails the
|
|
13
|
+
* build if a name and its meaning ever disagree.
|
|
14
|
+
*
|
|
15
|
+
* The naming convention lives with the names, in @hanzo/events and TAXONOMY.md:
|
|
16
|
+
* snake_case `<object>_<verb-past>`, a dimension is a PROPERTY and never part
|
|
17
|
+
* of the name, one name per user-visible moment shared by every surface.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { EVENTS, PAGEVIEW, EXCEPTION } from '@hanzo/events'
|
|
21
|
+
export type { EventName } from '@hanzo/events'
|
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/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.23'
|
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
|
-
})
|