@hanzo/event 0.3.2 → 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/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ declare function getFirstTouch(): Attribution | undefined;
6
6
  /** Read persisted cohort dimensions. */
7
7
  declare function getCohort(): Cohort | undefined;
8
8
 
9
- declare const VERSION = "0.3.2";
9
+ declare const VERSION = "0.3.3";
10
10
 
11
11
  /**
12
12
  * parseDsn parses "https://<version>:<hmac>@<host>/v1/sentry/<projectId>" into its
@@ -73,6 +73,12 @@ declare const EVENTS: {
73
73
  readonly SIGNUP_SUBMITTED: "signup_submitted";
74
74
  readonly SIGNUP_VERIFIED: "signup_verified";
75
75
  readonly SIGNUP_COMPLETED: "signup_completed";
76
+ /** A RETURNING user authenticated — the non-signup half of the IAM callback.
77
+ * Keeping it distinct is what stops returning logins from inflating signups. */
78
+ readonly LOGIN_COMPLETED: "login_completed";
79
+ /** Activation: the first moment of real value. ONE event for every product —
80
+ * the product-specific moment is the `action` property (api_call, app_live,
81
+ * chat_reply), never a new event name. */
76
82
  readonly FIRST_ACTION: "first_action";
77
83
  readonly WAITLIST_JOINED: "waitlist_joined";
78
84
  readonly WAITLIST_SHARED: "waitlist_shared";
@@ -85,24 +91,127 @@ declare const EVENTS: {
85
91
  readonly FEATURE_USED: "feature_used";
86
92
  readonly API_KEY_CREATED: "api_key_created";
87
93
  readonly APP_CREATED: "app_created";
88
- readonly DEPLOY_STARTED: "deploy_started";
89
94
  readonly PROJECT_CREATED: "project_created";
90
95
  readonly AGENT_CREATED: "agent_created";
91
96
  readonly CHAT_STARTED: "chat_started";
92
97
  readonly CHAT_MESSAGE_SENT: "chat_message_sent";
98
+ /** The user switched model/endpoint — the single strongest quality signal a
99
+ * chat surface emits (a switch usually follows a bad answer). */
100
+ readonly MODEL_SWITCHED: "model_switched";
93
101
  readonly TASK_STARTED: "task_started";
94
102
  readonly TASK_COMPLETED: "task_completed";
103
+ readonly BUILD_STARTED: "build_started";
104
+ /** A model finished producing an artifact (an app build, a chat reply, an agent
105
+ * run). Carries `durationMs` — the outcome event owns its own duration, so no
106
+ * paired start event is needed. */
107
+ readonly GENERATION_COMPLETED: "generation_completed";
108
+ readonly GENERATION_FAILED: "generation_failed";
109
+ readonly DEPLOY_STARTED: "deploy_started";
110
+ readonly DEPLOY_SUCCEEDED: "deploy_succeeded";
111
+ readonly DEPLOY_FAILED: "deploy_failed";
95
112
  };
96
113
  type EventName = (typeof EVENTS)[keyof typeof EVENTS];
97
114
  /** The reserved event name a pageview is stored under (server + read lens). */
98
115
  declare const PAGEVIEW = "$pageview";
99
116
 
117
+ /** The emitting surfaces — the closed set of `AnalyticsConfig.product` values.
118
+ * `product` is on every event, so a funnel scopes by product instead of every
119
+ * surface prefixing its event names. */
120
+ declare const PRODUCTS: readonly ["site", "app", "chat", "console", "admin", "cloud"];
121
+ type ProductId = (typeof PRODUCTS)[number];
122
+ interface FunnelStep {
123
+ /** An EVENTS value (or PAGEVIEW). */
124
+ event: string;
125
+ /** Human label for the Insights step. */
126
+ label: string;
127
+ /** Property equality that qualifies the step, e.g. first_action{action:'api_call'}. */
128
+ where?: {
129
+ property: string;
130
+ equals: string;
131
+ };
132
+ }
133
+ interface FunnelDef {
134
+ label: string;
135
+ /** Surface(s) the steps are emitted from — matched against the `product` field. */
136
+ products: ProductId[];
137
+ /**
138
+ * How steps are joined:
139
+ * • 'person' — steps join on distinctId (one browser, or one logged-in
140
+ * person across surfaces). The normal case.
141
+ * • 'aggregate' — steps are emitted on DIFFERENT origins by a LOGGED-OUT
142
+ * visitor, so there is no shared id: hanzo.ai, hanzo.app and
143
+ * hanzo.chat each mint their own anonymousId in their own
144
+ * storage. Read these as step-over-step COUNTS, never as a
145
+ * per-person conversion. Honest by construction.
146
+ */
147
+ join: 'person' | 'aggregate';
148
+ steps: FunnelStep[];
149
+ }
150
+ declare const FUNNELS: {
151
+ /** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
152
+ * redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
153
+ readonly signup: {
154
+ readonly label: "Signup";
155
+ readonly products: ["site"];
156
+ readonly join: "person";
157
+ readonly steps: [FunnelStep, FunnelStep, FunnelStep, FunnelStep, FunnelStep];
158
+ };
159
+ /** The developer activation path: an account is worth nothing until a key has
160
+ * made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
161
+ * Cloud on an org's first successful /v1 request — a browser cannot see it. */
162
+ readonly apiActivation: {
163
+ readonly label: "API activation";
164
+ readonly products: ["site", "cloud"];
165
+ readonly join: "person";
166
+ readonly steps: [FunnelStep, FunnelStep, FunnelStep];
167
+ };
168
+ /** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
169
+ readonly upgrade: {
170
+ readonly label: "Upgrade";
171
+ readonly products: ["site", "app", "console"];
172
+ readonly join: "person";
173
+ readonly steps: [FunnelStep, FunnelStep, FunnelStep, FunnelStep];
174
+ };
175
+ /** hanzo.app: describe → build → deploy → live URL. The whole product thesis
176
+ * in five steps; `deploy_succeeded` is the moment a live URL exists. */
177
+ readonly appShip: {
178
+ readonly label: "Describe → ship";
179
+ readonly products: ["app"];
180
+ readonly join: "person";
181
+ readonly steps: [FunnelStep, FunnelStep, FunnelStep, FunnelStep, FunnelStep];
182
+ };
183
+ /** hanzo.chat: visit → first message → answer. `generation_completed` is what
184
+ * separates "typed something" from "got value". */
185
+ readonly chatEngage: {
186
+ readonly label: "Chat engagement";
187
+ readonly products: ["chat"];
188
+ readonly join: "person";
189
+ readonly steps: [FunnelStep, FunnelStep, FunnelStep, FunnelStep];
190
+ };
191
+ /** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
192
+ * hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
193
+ * The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
194
+ * which makes the drop-off measurable without any cross-domain identity. */
195
+ readonly siteToChat: {
196
+ readonly label: "Site → Chat handoff";
197
+ readonly products: ["site", "chat"];
198
+ readonly join: "aggregate";
199
+ readonly steps: [FunnelStep, FunnelStep, FunnelStep];
200
+ };
201
+ };
202
+ type FunnelId = keyof typeof FUNNELS;
203
+ /** eventsOf flattens a funnel to its ordered event names — what a goal's `funnel`
204
+ * field carries, so the steps are defined exactly once (here). */
205
+ declare function eventsOf(id: FunnelId): string[];
206
+
100
207
  interface GoalDef {
101
208
  /** Human label shown in Insights. */
102
209
  label: string;
103
210
  /** The event whose occurrence counts as the goal conversion. */
104
211
  event: string;
105
- /** Optional ordered funnel leading to the goal (for funnel insights). */
212
+ /** The funnel leading to the goal — an id into FUNNELS (see funnels.ts). */
213
+ funnelId?: FunnelId;
214
+ /** The ordered event names of `funnelId`, derived — never hand-written. */
106
215
  funnel?: string[];
107
216
  /** Optional property equality filter that qualifies the conversion. */
108
217
  filter?: {
@@ -110,7 +219,7 @@ interface GoalDef {
110
219
  equals: string;
111
220
  };
112
221
  }
113
- declare const GOALS: Record<'signup' | 'sale' | 'upgradeIntent', GoalDef>;
222
+ declare const GOALS: Record<'signup' | 'sale' | 'upgradeIntent' | 'activation', GoalDef>;
114
223
  interface CohortDef {
115
224
  /** The hanzo.events column the cohort dimension maps to. */
116
225
  field: string;
@@ -131,4 +240,4 @@ declare function hasAttribution(a: Attribution): boolean;
131
240
  /** isoWeek returns the ISO-8601 week label, e.g. "2026-W28". */
132
241
  declare function isoWeek(d: Date): string;
133
242
 
134
- export { Attribution, COHORTS, CaptureErrorOptions, Cohort, type CohortDef, Dsn, EVENTS, type ErrorIdentity, type EventName, GOALS, type GoalDef, PAGEVIEW, SentryEvent, SentryFrame, VERSION, buildEnvelope, buildSentryEvent, deriveChannel, framesFromStack, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution, parseDsn, redactSecrets, scrubPII, scrubText };
243
+ export { Attribution, COHORTS, CaptureErrorOptions, Cohort, type CohortDef, Dsn, EVENTS, type ErrorIdentity, type EventName, FUNNELS, type FunnelDef, type FunnelId, type FunnelStep, GOALS, type GoalDef, PAGEVIEW, PRODUCTS, type ProductId, SentryEvent, SentryFrame, VERSION, buildEnvelope, buildSentryEvent, deriveChannel, eventsOf, framesFromStack, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution, parseDsn, redactSecrets, scrubPII, scrubText };
package/dist/index.mjs CHANGED
@@ -46,16 +46,16 @@ function deriveChannel(a) {
46
46
  }
47
47
  function hostOf(raw) {
48
48
  if (!raw) return "";
49
- let s = raw.trim();
50
- const scheme = s.indexOf("://");
51
- if (scheme >= 0) s = s.slice(scheme + 3);
52
- const cut = s.search(/[/?#]/);
53
- if (cut >= 0) s = s.slice(0, cut);
54
- const at = s.indexOf("@");
55
- if (at >= 0) s = s.slice(at + 1);
56
- const colon = s.indexOf(":");
57
- if (colon >= 0) s = s.slice(0, colon);
58
- return s.toLowerCase().trim();
49
+ let s2 = raw.trim();
50
+ const scheme = s2.indexOf("://");
51
+ if (scheme >= 0) s2 = s2.slice(scheme + 3);
52
+ const cut = s2.search(/[/?#]/);
53
+ if (cut >= 0) s2 = s2.slice(0, cut);
54
+ const at = s2.indexOf("@");
55
+ if (at >= 0) s2 = s2.slice(at + 1);
56
+ const colon = s2.indexOf(":");
57
+ if (colon >= 0) s2 = s2.slice(0, colon);
58
+ return s2.toLowerCase().trim();
59
59
  }
60
60
  function hasAttribution(a) {
61
61
  return Boolean(
@@ -78,6 +78,12 @@ var EVENTS = {
78
78
  SIGNUP_SUBMITTED: "signup_submitted",
79
79
  SIGNUP_VERIFIED: "signup_verified",
80
80
  SIGNUP_COMPLETED: "signup_completed",
81
+ /** A RETURNING user authenticated — the non-signup half of the IAM callback.
82
+ * Keeping it distinct is what stops returning logins from inflating signups. */
83
+ LOGIN_COMPLETED: "login_completed",
84
+ /** Activation: the first moment of real value. ONE event for every product —
85
+ * the product-specific moment is the `action` property (api_call, app_live,
86
+ * chat_reply), never a new event name. */
81
87
  FIRST_ACTION: "first_action",
82
88
  // Waitlist + referral.
83
89
  WAITLIST_JOINED: "waitlist_joined",
@@ -93,13 +99,27 @@ var EVENTS = {
93
99
  FEATURE_USED: "feature_used",
94
100
  API_KEY_CREATED: "api_key_created",
95
101
  APP_CREATED: "app_created",
96
- DEPLOY_STARTED: "deploy_started",
97
102
  PROJECT_CREATED: "project_created",
98
103
  AGENT_CREATED: "agent_created",
99
104
  CHAT_STARTED: "chat_started",
100
105
  CHAT_MESSAGE_SENT: "chat_message_sent",
106
+ /** The user switched model/endpoint — the single strongest quality signal a
107
+ * chat surface emits (a switch usually follows a bad answer). */
108
+ MODEL_SWITCHED: "model_switched",
101
109
  TASK_STARTED: "task_started",
102
- TASK_COMPLETED: "task_completed"
110
+ TASK_COMPLETED: "task_completed",
111
+ // Build → ship. `build_*` is a MODEL producing an artifact; `deploy_*` is that
112
+ // artifact going live. Intent (build_started) is never the same event as the
113
+ // artifact existing (app_created) — conflating them makes the funnel lie.
114
+ BUILD_STARTED: "build_started",
115
+ /** A model finished producing an artifact (an app build, a chat reply, an agent
116
+ * run). Carries `durationMs` — the outcome event owns its own duration, so no
117
+ * paired start event is needed. */
118
+ GENERATION_COMPLETED: "generation_completed",
119
+ GENERATION_FAILED: "generation_failed",
120
+ DEPLOY_STARTED: "deploy_started",
121
+ DEPLOY_SUCCEEDED: "deploy_succeeded",
122
+ DEPLOY_FAILED: "deploy_failed"
103
123
  };
104
124
  var PAGEVIEW = "$pageview";
105
125
 
@@ -151,40 +171,40 @@ function luhn(digits) {
151
171
  }
152
172
  return sum % 10 === 0;
153
173
  }
154
- function redactPAN(s) {
155
- return s.replace(RE_PAN, (m) => {
174
+ function redactPAN(s2) {
175
+ return s2.replace(RE_PAN, (m) => {
156
176
  const digits = m.replace(/[ -]/g, "");
157
177
  if (digits.length < 13 || digits.length > 19) return m;
158
178
  return luhn(digits) ? REDACTED : m;
159
179
  });
160
180
  }
161
- var RE_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
181
+ var RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g;
162
182
  var RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
163
183
  var RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g;
164
- function redactSecrets(s) {
165
- for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED);
166
- return redactPAN(s);
184
+ function redactSecrets(s2) {
185
+ for (const re of SECRET_PATTERNS) s2 = s2.replace(re, REDACTED);
186
+ return redactPAN(s2);
167
187
  }
168
- function scrubPII(s) {
169
- s = s.replace(RE_EMAIL, EMAIL_MARK);
170
- s = s.replace(RE_IPV6, IP_MARK);
171
- s = s.replace(RE_IPV4, IP_MARK);
172
- return s;
188
+ function scrubPII(s2) {
189
+ s2 = s2.replace(RE_EMAIL, EMAIL_MARK);
190
+ s2 = s2.replace(RE_IPV6, IP_MARK);
191
+ s2 = s2.replace(RE_IPV4, IP_MARK);
192
+ return s2;
173
193
  }
174
194
  var MAX_SCRUB_LEN = 8192;
175
- function truncate(s, max = MAX_SCRUB_LEN) {
176
- return s.length > max ? s.slice(0, max) + "\u2026 [truncated]" : s;
195
+ function truncate(s2, max = MAX_SCRUB_LEN) {
196
+ return s2.length > max ? s2.slice(0, max) + "\u2026 [truncated]" : s2;
177
197
  }
178
- function scrubText(s, capturePII = false) {
179
- if (!s) return s ?? "";
180
- s = truncate(s);
181
- s = redactSecrets(s);
182
- if (!capturePII) s = scrubPII(s);
183
- return s;
198
+ function scrubText(s2, capturePII = false) {
199
+ if (!s2) return s2 ?? "";
200
+ s2 = truncate(s2);
201
+ s2 = redactSecrets(s2);
202
+ if (!capturePII) s2 = scrubPII(s2);
203
+ return s2;
184
204
  }
185
205
 
186
206
  // src/version.ts
187
- var VERSION = "0.3.2";
207
+ var VERSION = "0.3.3";
188
208
 
189
209
  // src/sentry.ts
190
210
  var MAX_FRAMES = 50;
@@ -195,14 +215,14 @@ var MAX_TAGS = 50;
195
215
  function eventId() {
196
216
  const c = typeof crypto !== "undefined" ? crypto : void 0;
197
217
  if (c && "randomUUID" in c) return c.randomUUID().replace(/-/g, "");
198
- let s = "";
199
- for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
200
- return s;
218
+ let s2 = "";
219
+ for (let i = 0; i < 32; i++) s2 += Math.floor(Math.random() * 16).toString(16);
220
+ return s2;
201
221
  }
202
- function byteLen(s) {
203
- if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s).length;
204
- if (typeof Buffer !== "undefined") return Buffer.byteLength(s, "utf8");
205
- return s.length;
222
+ function byteLen(s2) {
223
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s2).length;
224
+ if (typeof Buffer !== "undefined") return Buffer.byteLength(s2, "utf8");
225
+ return s2.length;
206
226
  }
207
227
  function parseDsn(dsn) {
208
228
  if (!dsn) return null;
@@ -272,17 +292,38 @@ function framesFromStack(stack) {
272
292
  }
273
293
  function normalizeError(err) {
274
294
  if (err instanceof Error) {
275
- return { name: err.name || "Error", message: err.message || String(err), stack: err.stack };
295
+ const name = read(err, "name");
296
+ const message = read(err, "message");
297
+ const stack = read(err, "stack");
298
+ return {
299
+ name: typeof name === "string" && name ? name : "Error",
300
+ message: typeof message === "string" && message ? message : str(err),
301
+ stack: typeof stack === "string" ? stack : void 0
302
+ };
276
303
  }
277
304
  if (typeof err === "string") return { name: "Error", message: err };
278
305
  try {
279
- return { name: "Error", message: JSON.stringify(err) };
306
+ return { name: "Error", message: JSON.stringify(err) ?? str(err) };
307
+ } catch {
308
+ return { name: "Error", message: str(err) };
309
+ }
310
+ }
311
+ function read(o, k) {
312
+ try {
313
+ return o[k];
280
314
  } catch {
281
- return { name: "Error", message: String(err) };
315
+ return void 0;
316
+ }
317
+ }
318
+ function str(v) {
319
+ try {
320
+ return String(v);
321
+ } catch {
322
+ return "[unstringifiable]";
282
323
  }
283
324
  }
284
325
  function coerceTag(v) {
285
- const s = typeof v === "string" ? v : (() => {
326
+ const s2 = typeof v === "string" ? v : (() => {
286
327
  try {
287
328
  return JSON.stringify(v) ?? String(v);
288
329
  } catch {
@@ -293,7 +334,7 @@ function coerceTag(v) {
293
334
  }
294
335
  }
295
336
  })();
296
- return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s;
337
+ return s2.length > MAX_TAG_LEN ? s2.slice(0, MAX_TAG_LEN) : s2;
297
338
  }
298
339
  function buildSentryEvent(input) {
299
340
  const { error, options = {}, identity, capturePII = false } = input;
@@ -382,21 +423,21 @@ function uid() {
382
423
  return "a-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
383
424
  }
384
425
  function anonId() {
385
- const s = ls();
386
- if (!s) return void 0;
387
- let v = s.getItem(KEY.anon);
426
+ const s2 = ls();
427
+ if (!s2) return void 0;
428
+ let v = s2.getItem(KEY.anon);
388
429
  if (!v) {
389
430
  v = uid();
390
- s.setItem(KEY.anon, v);
431
+ s2.setItem(KEY.anon, v);
391
432
  }
392
433
  return v;
393
434
  }
394
435
  function sessionId(now = Date.now()) {
395
- const s = ls();
396
- if (!s) return void 0;
436
+ const s2 = ls();
437
+ if (!s2) return void 0;
397
438
  let state = null;
398
439
  try {
399
- state = JSON.parse(s.getItem(KEY.session) || "null");
440
+ state = JSON.parse(s2.getItem(KEY.session) || "null");
400
441
  } catch {
401
442
  state = null;
402
443
  }
@@ -405,45 +446,45 @@ function sessionId(now = Date.now()) {
405
446
  } else {
406
447
  state.last = now;
407
448
  }
408
- s.setItem(KEY.session, JSON.stringify(state));
449
+ s2.setItem(KEY.session, JSON.stringify(state));
409
450
  return state.id;
410
451
  }
411
452
  function getFirstTouch() {
412
- const s = ls();
413
- if (!s) return void 0;
453
+ const s2 = ls();
454
+ if (!s2) return void 0;
414
455
  try {
415
- const v = s.getItem(KEY.firstTouch);
456
+ const v = s2.getItem(KEY.firstTouch);
416
457
  return v ? JSON.parse(v) : void 0;
417
458
  } catch {
418
459
  return void 0;
419
460
  }
420
461
  }
421
462
  function setFirstTouchOnce(a) {
422
- const s = ls();
463
+ const s2 = ls();
423
464
  const existing = getFirstTouch();
424
465
  if (existing) return existing;
425
- if (s) s.setItem(KEY.firstTouch, JSON.stringify(a));
466
+ if (s2) s2.setItem(KEY.firstTouch, JSON.stringify(a));
426
467
  return a;
427
468
  }
428
469
  function getCohort() {
429
- const s = ls();
430
- if (!s) return void 0;
470
+ const s2 = ls();
471
+ if (!s2) return void 0;
431
472
  try {
432
- const v = s.getItem(KEY.cohort);
473
+ const v = s2.getItem(KEY.cohort);
433
474
  return v ? JSON.parse(v) : void 0;
434
475
  } catch {
435
476
  return void 0;
436
477
  }
437
478
  }
438
479
  function mergeCohort(patch) {
439
- const s = ls();
480
+ const s2 = ls();
440
481
  const cur = getCohort() || {};
441
482
  const next = {
442
483
  signupWeek: cur.signupWeek || patch.signupWeek,
443
484
  channel: patch.channel || cur.channel,
444
485
  refCode: cur.refCode || patch.refCode
445
486
  };
446
- if (s) s.setItem(KEY.cohort, JSON.stringify(next));
487
+ if (s2) s2.setItem(KEY.cohort, JSON.stringify(next));
447
488
  return next;
448
489
  }
449
490
 
@@ -476,15 +517,8 @@ function uid2() {
476
517
  return "m-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
477
518
  }
478
519
  function normalizeError2(err) {
479
- if (err instanceof Error) {
480
- return { type: err.name, message: err.message, stack: err.stack };
481
- }
482
- if (typeof err === "string") return { message: err };
483
- try {
484
- return { message: JSON.stringify(err) };
485
- } catch {
486
- return { message: String(err) };
487
- }
520
+ const n = normalizeError(err);
521
+ return { type: n.name, message: n.message, stack: n.stack };
488
522
  }
489
523
  var isBrowser = () => typeof window !== "undefined";
490
524
  function serializeBatch(batch) {
@@ -771,30 +805,139 @@ function createAnalytics(config) {
771
805
  return new Analytics(config);
772
806
  }
773
807
 
808
+ // src/funnels.ts
809
+ var PRODUCTS = ["site", "app", "chat", "console", "admin", "cloud"];
810
+ var s = (event, label, where) => ({
811
+ event,
812
+ label,
813
+ ...where ? { where } : {}
814
+ });
815
+ var FUNNELS = {
816
+ /** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
817
+ * redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
818
+ signup: {
819
+ label: "Signup",
820
+ products: ["site"],
821
+ join: "person",
822
+ steps: [
823
+ s(PAGEVIEW, "Landed"),
824
+ s(EVENTS.SIGNUP_VIEWED, "Opened signup"),
825
+ s(EVENTS.SIGNUP_SUBMITTED, "Redirected to Hanzo ID"),
826
+ s(EVENTS.SIGNUP_COMPLETED, "Account created"),
827
+ s(EVENTS.FIRST_ACTION, "First action")
828
+ ]
829
+ },
830
+ /** The developer activation path: an account is worth nothing until a key has
831
+ * made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
832
+ * Cloud on an org's first successful /v1 request — a browser cannot see it. */
833
+ apiActivation: {
834
+ label: "API activation",
835
+ products: ["site", "cloud"],
836
+ join: "person",
837
+ steps: [
838
+ s(EVENTS.SIGNUP_COMPLETED, "Account created"),
839
+ s(EVENTS.API_KEY_CREATED, "Key minted"),
840
+ s(EVENTS.FIRST_ACTION, "First successful API call", {
841
+ property: "action",
842
+ equals: "api_call"
843
+ })
844
+ ]
845
+ },
846
+ /** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
847
+ upgrade: {
848
+ label: "Upgrade",
849
+ products: ["site", "app", "console"],
850
+ join: "person",
851
+ steps: [
852
+ s(EVENTS.PRICING_VIEWED, "Viewed pricing"),
853
+ s(EVENTS.PLAN_CLICKED, "Chose a plan"),
854
+ s(EVENTS.CHECKOUT_STARTED, "Started checkout"),
855
+ s(EVENTS.ORDER_COMPLETED, "Paid")
856
+ ]
857
+ },
858
+ /** hanzo.app: describe → build → deploy → live URL. The whole product thesis
859
+ * in five steps; `deploy_succeeded` is the moment a live URL exists. */
860
+ appShip: {
861
+ label: "Describe \u2192 ship",
862
+ products: ["app"],
863
+ join: "person",
864
+ steps: [
865
+ s(PAGEVIEW, "Landed"),
866
+ s(EVENTS.BUILD_STARTED, "Described an app"),
867
+ s(EVENTS.GENERATION_COMPLETED, "Got a working build"),
868
+ s(EVENTS.DEPLOY_STARTED, "Hit publish"),
869
+ s(EVENTS.DEPLOY_SUCCEEDED, "Live URL")
870
+ ]
871
+ },
872
+ /** hanzo.chat: visit → first message → answer. `generation_completed` is what
873
+ * separates "typed something" from "got value". */
874
+ chatEngage: {
875
+ label: "Chat engagement",
876
+ products: ["chat"],
877
+ join: "person",
878
+ steps: [
879
+ s(PAGEVIEW, "Landed"),
880
+ s(EVENTS.CHAT_STARTED, "Started a conversation"),
881
+ s(EVENTS.CHAT_MESSAGE_SENT, "Sent a message"),
882
+ s(EVENTS.GENERATION_COMPLETED, "Got an answer")
883
+ ]
884
+ },
885
+ /** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
886
+ * hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
887
+ * The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
888
+ * which makes the drop-off measurable without any cross-domain identity. */
889
+ siteToChat: {
890
+ label: "Site \u2192 Chat handoff",
891
+ products: ["site", "chat"],
892
+ join: "aggregate",
893
+ steps: [
894
+ s(EVENTS.CHAT_STARTED, "Submitted the hanzo.ai composer", {
895
+ property: "source",
896
+ equals: "composer"
897
+ }),
898
+ s(EVENTS.CHAT_STARTED, "Landed in hanzo.chat", {
899
+ property: "referrerProduct",
900
+ equals: "site"
901
+ }),
902
+ s(EVENTS.GENERATION_COMPLETED, "Got an answer")
903
+ ]
904
+ }
905
+ };
906
+ function eventsOf(id) {
907
+ return FUNNELS[id].steps.map((step) => step.event);
908
+ }
909
+
774
910
  // src/goals.ts
775
911
  var GOALS = {
776
- // Signup: the conversion is signup_completed; the funnel is the four steps.
912
+ // Signup: the conversion is signup_completed, along the site signup funnel.
777
913
  signup: {
778
914
  label: "Signup",
779
915
  event: EVENTS.SIGNUP_COMPLETED,
780
- funnel: [
781
- EVENTS.SIGNUP_VIEWED,
782
- EVENTS.SIGNUP_SUBMITTED,
783
- EVENTS.SIGNUP_VERIFIED,
784
- EVENTS.FIRST_ACTION
785
- ]
916
+ funnelId: "signup",
917
+ funnel: eventsOf("signup")
786
918
  },
787
919
  // Sale: a completed order qualified as a plan purchase (kind=plan).
788
920
  sale: {
789
921
  label: "Sale",
790
922
  event: EVENTS.ORDER_COMPLETED,
923
+ funnelId: "upgrade",
924
+ funnel: eventsOf("upgrade"),
791
925
  filter: { property: "kind", equals: "plan" }
792
926
  },
793
927
  // Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
794
928
  upgradeIntent: {
795
929
  label: "Upgrade Intent",
796
930
  event: EVENTS.PLAN_CLICKED,
797
- funnel: [EVENTS.PRICING_VIEWED, EVENTS.PLAN_CLICKED, EVENTS.CHECKOUT_STARTED]
931
+ funnelId: "upgrade",
932
+ funnel: eventsOf("upgrade")
933
+ },
934
+ // Activation: the ONE north-star conversion — an account that did the first
935
+ // valuable thing (a successful API call, a live app, a chat answer).
936
+ activation: {
937
+ label: "Activation",
938
+ event: EVENTS.FIRST_ACTION,
939
+ funnelId: "apiActivation",
940
+ funnel: eventsOf("apiActivation")
798
941
  }
799
942
  };
800
943
  var COHORTS = {
@@ -803,6 +946,6 @@ var COHORTS = {
803
946
  refCode: { field: "ref_code", label: "Referral code" }
804
947
  };
805
948
 
806
- export { Analytics, COHORTS, EVENTS, GOALS, PAGEVIEW, VERSION, buildEnvelope, buildSentryEvent, createAnalytics, deriveChannel, framesFromStack, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution, parseDsn, redactSecrets, scrubPII, scrubText };
949
+ export { Analytics, COHORTS, EVENTS, FUNNELS, GOALS, PAGEVIEW, PRODUCTS, VERSION, buildEnvelope, buildSentryEvent, createAnalytics, deriveChannel, eventsOf, framesFromStack, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution, parseDsn, redactSecrets, scrubPII, scrubText };
807
950
  //# sourceMappingURL=index.mjs.map
808
951
  //# sourceMappingURL=index.mjs.map