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