@hanzo/event 0.3.3 → 0.3.4

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/TAXONOMY.md ADDED
@@ -0,0 +1,257 @@
1
+ # Hanzo product analytics — the taxonomy
2
+
3
+ The canonical event vocabulary, property rules, identity semantics, and funnels
4
+ for **hanzo.ai**, **hanzo.app**, and **hanzo.chat**. One definition, three
5
+ surfaces.
6
+
7
+ ## Why this document lives here
8
+
9
+ The taxonomy is **code**, not prose: `src/events.ts` (`EVENTS`), `src/funnels.ts`
10
+ (`FUNNELS`, `PRODUCTS`), `src/goals.ts` (`GOALS`, `COHORTS`). All three apps
11
+ already `import { EVENTS } from '@hanzo/event'`, so this package is the only
12
+ place a definition can live and be *used* rather than merely described.
13
+
14
+ It is deliberately **not** in `hanzo/insights`. Insights is the read lens — it
15
+ renders whatever arrives. A funnel defined there could name an event no surface
16
+ emits and nobody would find out; a funnel defined next to `EVENTS` cannot, because
17
+ `funnels.test.ts` fails the build. Docs that sit apart from the constants they
18
+ describe drift within a release. These do not.
19
+
20
+ ```
21
+ @hanzo/event ── EVENTS ──┬── hanzo.ai (product: 'site')
22
+ FUNNELS ├── hanzo.app (product: 'app')
23
+ GOALS ├── hanzo.chat (product: 'chat')
24
+ └── insights.hanzo.ai (reads; never defines)
25
+ ```
26
+
27
+ ---
28
+
29
+ ## 1. The rules
30
+
31
+ ### Naming
32
+
33
+ | Rule | Yes | No |
34
+ |---|---|---|
35
+ | `snake_case`, `<object>_<verb-past>` | `deploy_succeeded` | `DeploySuccess`, `deploy-ok` |
36
+ | Names are constants, never interpolated | `deploy_succeeded` + `{framework:'static'}` | `deploy_static_succeeded` |
37
+ | Dimensions are properties | `plan_clicked` + `{plan:'pro'}` | `pro_plan_clicked` |
38
+ | No product prefix — `product` is already on the wire | `chat_started` | `chat_app_chat_started` |
39
+ | `$` is reserved for the client | `$pageview` (emitted by `pageview()`) | any app-authored `$name` |
40
+ | One name per user-visible moment, shared by all surfaces | `signup_completed` everywhere | `signup_done` / `registered` |
41
+
42
+ Enforced by `funnels.test.ts` (`event vocabulary` block). Adding a name means
43
+ adding it to `EVENTS` — a raw string in `capture()` is a bug, and the string will
44
+ be invisible to every funnel.
45
+
46
+ ### Properties
47
+
48
+ Every event already carries, with no app code: `messageId`, `type`, `timestamp`,
49
+ `distinctId`, `anonymousId`, `personId`, `sessionId`, `product`, `referrer`,
50
+ `utm.*`, `channel`, `refCode`, `signupWeek`, `library`, `libraryVersion`. **Never
51
+ re-send these** as properties.
52
+
53
+ App-supplied properties:
54
+
55
+ - **Low cardinality, enumerated.** `mode`, `framework`, `plan`, `source`,
56
+ `endpoint`, `model`, `provider`, `status`, `reason`. A property whose value
57
+ space is unbounded is a log line, not a dimension.
58
+ - **Never user content.** No prompt, message, project name, file path, URL,
59
+ email, or org display name. The client is PII-free by construction; keep it so.
60
+ - **Booleans read as predicates**: `hasPrompt`, `hasImages`, `isUpdate`.
61
+ - **Durations are `durationMs`** (number, milliseconds). Outcome events own their
62
+ duration, which is why there is no paired `*_started` for generations.
63
+ - **Money never goes in properties** — use the commerce fields on `capture()`:
64
+ `capture(EVENTS.ORDER_COMPLETED, { kind: 'plan' }, { productId, revenue, currency, quantity })`.
65
+ - **Errors are events.** `captureError(err, { properties: { where: 'publish' } })`
66
+ puts the exception in the sentry lens *on the same stream*, so a funnel drop
67
+ joins to the exception that caused it. Always pass `where`.
68
+
69
+ ### Identity — `identify` and `group`
70
+
71
+ Exactly one place per app, mounted where the session resolves. Never at a call
72
+ site.
73
+
74
+ ```tsx
75
+ analytics.identify(user.id) // the stable IAM subject (OIDC `sub`). NEVER email.
76
+ analytics.group(orgId) // the org
77
+ ```
78
+
79
+ - **`identify`** joins a person's pre- and post-login events, and joins them
80
+ across surfaces (the same subject arrives from Cloud server-side). Traits, if
81
+ any, must be non-PII (`plan`, `role`).
82
+ - **`group`** is what makes org-level questions answerable — *"which orgs stalled
83
+ before their first deploy?"* Cloud already resolves the tenant server-side for
84
+ billing and stamps it; `group()` is the analytics dimension, and without it no
85
+ B2B funnel exists. It was missing everywhere before this revision.
86
+ - **`setCohort({ signupWeek })`** is stamped once, at account birth, so every
87
+ later event carries the acquisition week and retention curves are free.
88
+ - **Never** send email, name, or phone. Not as a trait, not as a property.
89
+
90
+ ### Cross-origin identity — the honest limit
91
+
92
+ hanzo.ai, hanzo.app, and hanzo.chat are **different origins**. A logged-out
93
+ visitor therefore has a **different `anonymousId` on each**; nothing joins them.
94
+ Only `personId` (post-login) spans surfaces.
95
+
96
+ So a funnel that crosses origins while logged out is declared
97
+ `join: 'aggregate'` in `FUNNELS` and read as step-over-step **counts**, never as
98
+ a per-person conversion rate. The join key is an explicit property: the hanzo.ai
99
+ composer appends `?hz_ref=site`, and hanzo.chat stamps `referrerProduct:'site'`
100
+ on its own `chat_started`. Measurable, without cross-domain tracking.
101
+
102
+ ---
103
+
104
+ ## 2. The funnels
105
+
106
+ Machine-readable in `src/funnels.ts`; `GOALS` reference them by id.
107
+
108
+ ### hanzo.ai — land → signup → activation
109
+
110
+ ```
111
+ $pageview ──▶ signup_viewed ──▶ signup_submitted ──▶ signup_completed ──▶ first_action
112
+ (land) (/signup) (redirect to IAM) (/auth/callback) {action:'api_call'}
113
+ ```
114
+
115
+ `FUNNELS.signup` + `FUNNELS.apiActivation`. IAM hosts the form, so `submitted` is
116
+ the redirect *into* IAM and `completed` is the return. `first_action{action:
117
+ 'api_call'}` is emitted **server-side by Cloud** on an org's first successful
118
+ `/v1` request — a browser cannot observe it, and this is the only step that
119
+ proves the account was worth acquiring.
120
+
121
+ Secondary: `FUNNELS.upgrade` (`pricing_viewed → plan_clicked → checkout_started →
122
+ order_completed`) and `FUNNELS.siteToChat` (the composer handoff, aggregate).
123
+
124
+ ### hanzo.app — describe → build → ship
125
+
126
+ ```
127
+ $pageview ──▶ build_started ──▶ generation_completed ──▶ deploy_started ──▶ deploy_succeeded
128
+ (land) (composer) (working build) (publish) (live URL)
129
+ ```
130
+
131
+ `FUNNELS.appShip`. The product thesis in five steps. `deploy_succeeded` fires
132
+ only when a live URL is in hand — never inferred from "started and no error".
133
+ The first one also emits `first_action{action:'app_live'}`.
134
+
135
+ ### hanzo.chat — visit → first message → answer
136
+
137
+ ```
138
+ $pageview ──▶ chat_started ──▶ chat_message_sent ──▶ generation_completed
139
+ (land) (new convo) (per message) (answer, durationMs)
140
+ ```
141
+
142
+ `FUNNELS.chatEngage`. `chat_started` carries `referrerProduct` when the visitor
143
+ came from the hanzo.ai composer. `model_switched` is the quality signal that
144
+ sits *beside* the funnel: a switch mid-conversation usually follows a bad answer,
145
+ and `fromModel`/`model` is what makes "which model gets abandoned" answerable.
146
+ Return/retention is a cohort question (`signupWeek` × `$pageview`), not an event.
147
+
148
+ ---
149
+
150
+ ## 3. Emit sites — what exists, what is missing
151
+
152
+ Verified against the trees at the time of writing. ✅ = emitting, ❌ = not emitted.
153
+
154
+ ### hanzo.ai (`~/work/hanzo/hanzo.ai`, `product: 'site'`)
155
+
156
+ | Event | Where | State |
157
+ |---|---|---|
158
+ | `$pageview` | `app/providers.tsx` (`AnalyticsProvider` + `usePageview`) | ✅ |
159
+ | `identify` / `group` | `app/providers.tsx:72,75` (`Identity`) | ✅ **added** |
160
+ | `signup_viewed` | `app/(marketing)/signup/page.tsx:19` | ✅ |
161
+ | `referral_used` | `app/(marketing)/signup/page.tsx:21` | ✅ |
162
+ | `signup_submitted` | `app/(marketing)/signup/page.tsx:24` | ✅ |
163
+ | `signup_completed` | `app/(marketing)/auth/callback/page.tsx:39` | ✅ **added** |
164
+ | `login_completed` | `app/(marketing)/auth/callback/page.tsx:41` | ✅ **added** |
165
+ | `setCohort({signupWeek})` | `app/(marketing)/auth/callback/page.tsx:38` | ✅ **added** |
166
+ | auth-exchange failure | `app/(marketing)/auth/callback/page.tsx:49` | ✅ **added** |
167
+ | `chat_started` | `components/home/ChatHero.tsx:58,66`, `components/home/LandingNav.tsx:90` | ✅ |
168
+ | `feature_used` | `components/home/ChatHero.tsx:63` | ✅ |
169
+ | `pricing_viewed` | `app/(marketing)/pricing/page.tsx:34` | ✅ |
170
+ | `plan_clicked` | `components/pricing/PricingPlan.tsx:43` | ✅ |
171
+ | `waitlist_joined` / `_shared` | `app/(marketing)/research-access/page.tsx:95`, `components/referrals/ReferralLink.tsx:27,47` | ✅ |
172
+ | `referral_claimed` | `components/referrals/TryFreeCoupon.tsx:32` | ✅ |
173
+ | `checkout_started` | `app/(marketing)/account/billing-plans/page.tsx:90` (`checkout(planId)`) | ❌ **missing** |
174
+ | `order_completed` | — (server-side; commerce owns the truth) | ❌ **missing** |
175
+ | `api_key_created` | — (no key UI on this site; console.hanzo.ai owns it) | ❌ n/a here |
176
+ | `first_action{action:'api_call'}` | — (Cloud, on first successful `/v1` request) | ❌ **missing — server-side** |
177
+
178
+ ### hanzo.app (`~/work/hanzo/app`, `product: 'app'`)
179
+
180
+ | Event | Where | State |
181
+ |---|---|---|
182
+ | `$pageview`, errors | `components/providers/analytics.tsx` (`AnalyticsRoot`) | ✅ |
183
+ | `identify` | `components/providers/analytics.tsx:47` | ✅ |
184
+ | `group` | `components/providers/analytics.tsx:55` | ✅ **added** |
185
+ | `build_started` | `components/build-composer/index.tsx:173` | ✅ **fixed** (was `app_created` — intent mislabelled as creation) |
186
+ | `chat_message_sent` | `components/chat-panel/index.tsx:242` | ✅ |
187
+ | `generation_completed` | `components/workspace/index.tsx:1051` | ✅ **added** |
188
+ | `generation_failed` | `components/workspace/index.tsx:1065,1092` | ✅ **added** |
189
+ | `project_created` | `components/project-manager/index.tsx:299` | ✅ |
190
+ | `deploy_started` | `components/editor/deploy-button/content.tsx:61` | ✅ |
191
+ | `deploy_succeeded` | `components/editor/deploy-button/content.tsx:156` | ✅ **added** |
192
+ | `first_action{action:'app_live'}` | `components/editor/deploy-button/content.tsx:164` | ✅ **added** |
193
+ | `deploy_failed` | `components/editor/deploy-button/content.tsx:95,183` | ✅ **added** |
194
+ | `pricing_viewed` / `plan_clicked` | `app/pricing/page.tsx:87,99` | ✅ |
195
+ | `signup_viewed` / `_submitted` | `app/signup/page.tsx:26,30` | ✅ |
196
+ | `signup_completed` | — (this app has no IAM callback route of its own) | ❌ **missing** |
197
+
198
+ ### hanzo.chat (`~/work/hanzo/chat`, `product: 'chat'`)
199
+
200
+ | Event | Where | State |
201
+ |---|---|---|
202
+ | `$pageview` | `client/src/Providers/AnalyticsProvider.tsx` | ✅ |
203
+ | `identify` | `client/src/Providers/AnalyticsProvider.tsx` (`AnalyticsBridge`) | ✅ |
204
+ | `chat_started` (+ `referrerProduct`) | `client/src/hooks/Chat/useChatFunctions.ts:138` | ✅ **enriched** |
205
+ | `chat_message_sent` | `client/src/hooks/Chat/useChatFunctions.ts:144` | ✅ |
206
+ | `generation_completed` | `client/src/hooks/SSE/useSSE.ts:138` | ✅ **added** |
207
+ | `generation_failed` | `client/src/hooks/SSE/useSSE.ts:251` | ✅ **added** |
208
+ | `model_switched` | `client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx:233` | ✅ **added** |
209
+ | `group` | — (no org concept in the chat session today) | ❌ **missing** |
210
+ | `signup_completed` | — (IAM session-bridge, no callback route here) | ❌ **missing** |
211
+
212
+ ---
213
+
214
+ ## 4. Known gaps — ranked
215
+
216
+ 1. **hanzo.app runs a second, parallel telemetry pipe.** `lib/telemetry/`
217
+ (`config.ts`, `tracker.ts`, `events.ts`) POSTs its own vocabulary
218
+ (`task_started`, `task_complete`, `task_fail`, `model_selected`, `pageview`,
219
+ `heartbeat`) with its own visitor id and its own batching to
220
+ `https://console.hanzo.ai/api/public/otel/v1/traces`. That is a **frontend
221
+ host**, with an **`/api/` prefix**, and a vocabulary disjoint from this one —
222
+ so nothing in that stream can be joined to a funnel in this one. Both fire
223
+ today at `components/workspace/index.tsx` (`track('task_complete')` beside
224
+ `capture(GENERATION_COMPLETED)`).
225
+ *Fix:* reduce `lib/telemetry`'s `track()` to a thin adapter over the shared
226
+ `@hanzo/event` client (name-map `task_complete → generation_completed`,
227
+ `model_selected → model_switched`), delete `tracker.ts` + `config.ts`, and the
228
+ duplicate queue/retry/heartbeat/visitor-id machinery goes with it.
229
+ 2. **`first_action` is emitted by nobody on the API path.** Activation — the one
230
+ metric that matters — needs Cloud to emit `first_action{action:'api_call'}`
231
+ on an org's first successful `/v1` request. `FUNNELS.apiActivation` is
232
+ specified and unmeasurable until it does. `hanzo.app` now covers its own
233
+ variant (`action:'app_live'`).
234
+ 3. **`checkout_started` / `order_completed` are unwired.** `hanzo.ai`'s
235
+ `account/billing-plans/page.tsx:90` calls `checkout(planId)` and captures
236
+ nothing, so `GOALS.sale` has no data and `FUNNELS.upgrade` truncates at
237
+ `plan_clicked`.
238
+ 4. **No org on hanzo.chat.** `group()` is absent because the chat session carries
239
+ no org today; chat funnels are person-scoped only.
240
+ 5. **Logged-out reach depends on a publishable key.** Anonymous events need
241
+ `ingestKey` (`pk_…`, write-only) or they fail closed at the door.
242
+ `NEXT_PUBLIC_HANZO_INGEST_KEY` (hanzo.ai), `NEXT_PUBLIC_EVENT_INGEST_KEY`
243
+ (hanzo.app), `VITE_HANZO_INGEST_KEY` (hanzo.chat) are read but must be
244
+ provisioned per org via `POST /v1/ingest/keys`. Config, not code.
245
+
246
+ ---
247
+
248
+ ## 5. Adding an event
249
+
250
+ 1. Add the constant to `EVENTS` in `src/events.ts` — with a comment saying which
251
+ decision it informs. If you cannot name one, do not add it.
252
+ 2. If it is a funnel step, add it to the funnel in `src/funnels.ts`. If it is a
253
+ conversion, point a `GOALS` entry at that funnel id.
254
+ 3. `pnpm test` — the drift guard will reject a step whose event does not exist.
255
+ 4. Emit it from **one** place per app, with enumerated properties only.
256
+ 5. Bump the patch version; the `hanzoai/ui` publish workflow ships it on merge to
257
+ `main`, and apps pick it up on their next install.
package/dist/index.cjs CHANGED
@@ -73,6 +73,20 @@ function isoWeek(d) {
73
73
  return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
74
74
  }
75
75
 
76
+ // src/dsn.ts
77
+ var PRODUCT_DSN = Object.freeze({
78
+ // hanzo-console — console.hanzo.ai (also served embedded by the cloud binary)
79
+ console: "https://1:0c8054dbde157f4f420c56b58660052b2ad782293c4de1d606ef8fbc46a0bf34@api.hanzo.ai/v1/sentry/019fa40b-94ae-7f1d-8f7b-e92f123fad42",
80
+ // hanzo-app — hanzo.app
81
+ app: "https://1:b3e1173125568c80f91ef4b1fabbbd2d7e22341de02b33ce7e22ef4fc16a196e@api.hanzo.ai/v1/sentry/019f9b1e-57eb-7171-9d92-72c0b85e4b4b",
82
+ // hanzo-ai — hanzo.ai (the marketing site; `site` is the product name it declares)
83
+ site: "https://1:d9cbfb844958bd7ef2a455600f00fbf237fbd71b75c1504f137773096d6aa53f@api.hanzo.ai/v1/sentry/019f9b1e-5785-7359-ad0b-f75db8e58c99"
84
+ });
85
+ function dsnForProduct(product) {
86
+ if (!product) return void 0;
87
+ return PRODUCT_DSN[product];
88
+ }
89
+
76
90
  // src/events.ts
77
91
  var EVENTS = {
78
92
  // Signup funnel: view -> submit -> verify -> completed -> first action.
@@ -593,7 +607,7 @@ var Analytics = class {
593
607
  ...config
594
608
  };
595
609
  this.transport = config.transport ?? new DefaultTransport();
596
- this.dsn = parseDsn(config.dsn ?? readEnvDsn());
610
+ this.dsn = parseDsn(config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product));
597
611
  }
598
612
  /** errorPlaneEnabled reports whether captured exceptions can actually reach the
599
613
  * error host. False means a DSN was never configured — the documented
@@ -955,11 +969,13 @@ exports.FUNNELS = FUNNELS;
955
969
  exports.GOALS = GOALS;
956
970
  exports.PAGEVIEW = PAGEVIEW;
957
971
  exports.PRODUCTS = PRODUCTS;
972
+ exports.PRODUCT_DSN = PRODUCT_DSN;
958
973
  exports.VERSION = VERSION;
959
974
  exports.buildEnvelope = buildEnvelope;
960
975
  exports.buildSentryEvent = buildSentryEvent;
961
976
  exports.createAnalytics = createAnalytics;
962
977
  exports.deriveChannel = deriveChannel;
978
+ exports.dsnForProduct = dsnForProduct;
963
979
  exports.eventsOf = eventsOf;
964
980
  exports.framesFromStack = framesFromStack;
965
981
  exports.getCohort = getCohort;