@hanzo/event 0.3.2 → 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/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(
@@ -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.
@@ -80,6 +94,12 @@ var EVENTS = {
80
94
  SIGNUP_SUBMITTED: "signup_submitted",
81
95
  SIGNUP_VERIFIED: "signup_verified",
82
96
  SIGNUP_COMPLETED: "signup_completed",
97
+ /** A RETURNING user authenticated — the non-signup half of the IAM callback.
98
+ * Keeping it distinct is what stops returning logins from inflating signups. */
99
+ LOGIN_COMPLETED: "login_completed",
100
+ /** Activation: the first moment of real value. ONE event for every product —
101
+ * the product-specific moment is the `action` property (api_call, app_live,
102
+ * chat_reply), never a new event name. */
83
103
  FIRST_ACTION: "first_action",
84
104
  // Waitlist + referral.
85
105
  WAITLIST_JOINED: "waitlist_joined",
@@ -95,13 +115,27 @@ var EVENTS = {
95
115
  FEATURE_USED: "feature_used",
96
116
  API_KEY_CREATED: "api_key_created",
97
117
  APP_CREATED: "app_created",
98
- DEPLOY_STARTED: "deploy_started",
99
118
  PROJECT_CREATED: "project_created",
100
119
  AGENT_CREATED: "agent_created",
101
120
  CHAT_STARTED: "chat_started",
102
121
  CHAT_MESSAGE_SENT: "chat_message_sent",
122
+ /** The user switched model/endpoint — the single strongest quality signal a
123
+ * chat surface emits (a switch usually follows a bad answer). */
124
+ MODEL_SWITCHED: "model_switched",
103
125
  TASK_STARTED: "task_started",
104
- TASK_COMPLETED: "task_completed"
126
+ TASK_COMPLETED: "task_completed",
127
+ // Build → ship. `build_*` is a MODEL producing an artifact; `deploy_*` is that
128
+ // artifact going live. Intent (build_started) is never the same event as the
129
+ // artifact existing (app_created) — conflating them makes the funnel lie.
130
+ BUILD_STARTED: "build_started",
131
+ /** A model finished producing an artifact (an app build, a chat reply, an agent
132
+ * run). Carries `durationMs` — the outcome event owns its own duration, so no
133
+ * paired start event is needed. */
134
+ GENERATION_COMPLETED: "generation_completed",
135
+ GENERATION_FAILED: "generation_failed",
136
+ DEPLOY_STARTED: "deploy_started",
137
+ DEPLOY_SUCCEEDED: "deploy_succeeded",
138
+ DEPLOY_FAILED: "deploy_failed"
105
139
  };
106
140
  var PAGEVIEW = "$pageview";
107
141
 
@@ -153,40 +187,40 @@ function luhn(digits) {
153
187
  }
154
188
  return sum % 10 === 0;
155
189
  }
156
- function redactPAN(s) {
157
- return s.replace(RE_PAN, (m) => {
190
+ function redactPAN(s2) {
191
+ return s2.replace(RE_PAN, (m) => {
158
192
  const digits = m.replace(/[ -]/g, "");
159
193
  if (digits.length < 13 || digits.length > 19) return m;
160
194
  return luhn(digits) ? REDACTED : m;
161
195
  });
162
196
  }
163
- var RE_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
197
+ var RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g;
164
198
  var RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
165
199
  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);
200
+ function redactSecrets(s2) {
201
+ for (const re of SECRET_PATTERNS) s2 = s2.replace(re, REDACTED);
202
+ return redactPAN(s2);
169
203
  }
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;
204
+ function scrubPII(s2) {
205
+ s2 = s2.replace(RE_EMAIL, EMAIL_MARK);
206
+ s2 = s2.replace(RE_IPV6, IP_MARK);
207
+ s2 = s2.replace(RE_IPV4, IP_MARK);
208
+ return s2;
175
209
  }
176
210
  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;
211
+ function truncate(s2, max = MAX_SCRUB_LEN) {
212
+ return s2.length > max ? s2.slice(0, max) + "\u2026 [truncated]" : s2;
179
213
  }
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;
214
+ function scrubText(s2, capturePII = false) {
215
+ if (!s2) return s2 ?? "";
216
+ s2 = truncate(s2);
217
+ s2 = redactSecrets(s2);
218
+ if (!capturePII) s2 = scrubPII(s2);
219
+ return s2;
186
220
  }
187
221
 
188
222
  // src/version.ts
189
- var VERSION = "0.3.2";
223
+ var VERSION = "0.3.3";
190
224
 
191
225
  // src/sentry.ts
192
226
  var MAX_FRAMES = 50;
@@ -197,14 +231,14 @@ var MAX_TAGS = 50;
197
231
  function eventId() {
198
232
  const c = typeof crypto !== "undefined" ? crypto : void 0;
199
233
  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;
234
+ let s2 = "";
235
+ for (let i = 0; i < 32; i++) s2 += Math.floor(Math.random() * 16).toString(16);
236
+ return s2;
203
237
  }
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;
238
+ function byteLen(s2) {
239
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s2).length;
240
+ if (typeof Buffer !== "undefined") return Buffer.byteLength(s2, "utf8");
241
+ return s2.length;
208
242
  }
209
243
  function parseDsn(dsn) {
210
244
  if (!dsn) return null;
@@ -274,17 +308,38 @@ function framesFromStack(stack) {
274
308
  }
275
309
  function normalizeError(err) {
276
310
  if (err instanceof Error) {
277
- return { name: err.name || "Error", message: err.message || String(err), stack: err.stack };
311
+ const name = read(err, "name");
312
+ const message = read(err, "message");
313
+ const stack = read(err, "stack");
314
+ return {
315
+ name: typeof name === "string" && name ? name : "Error",
316
+ message: typeof message === "string" && message ? message : str(err),
317
+ stack: typeof stack === "string" ? stack : void 0
318
+ };
278
319
  }
279
320
  if (typeof err === "string") return { name: "Error", message: err };
280
321
  try {
281
- return { name: "Error", message: JSON.stringify(err) };
322
+ return { name: "Error", message: JSON.stringify(err) ?? str(err) };
323
+ } catch {
324
+ return { name: "Error", message: str(err) };
325
+ }
326
+ }
327
+ function read(o, k) {
328
+ try {
329
+ return o[k];
282
330
  } catch {
283
- return { name: "Error", message: String(err) };
331
+ return void 0;
332
+ }
333
+ }
334
+ function str(v) {
335
+ try {
336
+ return String(v);
337
+ } catch {
338
+ return "[unstringifiable]";
284
339
  }
285
340
  }
286
341
  function coerceTag(v) {
287
- const s = typeof v === "string" ? v : (() => {
342
+ const s2 = typeof v === "string" ? v : (() => {
288
343
  try {
289
344
  return JSON.stringify(v) ?? String(v);
290
345
  } catch {
@@ -295,7 +350,7 @@ function coerceTag(v) {
295
350
  }
296
351
  }
297
352
  })();
298
- return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s;
353
+ return s2.length > MAX_TAG_LEN ? s2.slice(0, MAX_TAG_LEN) : s2;
299
354
  }
300
355
  function buildSentryEvent(input) {
301
356
  const { error, options = {}, identity, capturePII = false } = input;
@@ -384,21 +439,21 @@ function uid() {
384
439
  return "a-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
385
440
  }
386
441
  function anonId() {
387
- const s = ls();
388
- if (!s) return void 0;
389
- let v = s.getItem(KEY.anon);
442
+ const s2 = ls();
443
+ if (!s2) return void 0;
444
+ let v = s2.getItem(KEY.anon);
390
445
  if (!v) {
391
446
  v = uid();
392
- s.setItem(KEY.anon, v);
447
+ s2.setItem(KEY.anon, v);
393
448
  }
394
449
  return v;
395
450
  }
396
451
  function sessionId(now = Date.now()) {
397
- const s = ls();
398
- if (!s) return void 0;
452
+ const s2 = ls();
453
+ if (!s2) return void 0;
399
454
  let state = null;
400
455
  try {
401
- state = JSON.parse(s.getItem(KEY.session) || "null");
456
+ state = JSON.parse(s2.getItem(KEY.session) || "null");
402
457
  } catch {
403
458
  state = null;
404
459
  }
@@ -407,45 +462,45 @@ function sessionId(now = Date.now()) {
407
462
  } else {
408
463
  state.last = now;
409
464
  }
410
- s.setItem(KEY.session, JSON.stringify(state));
465
+ s2.setItem(KEY.session, JSON.stringify(state));
411
466
  return state.id;
412
467
  }
413
468
  function getFirstTouch() {
414
- const s = ls();
415
- if (!s) return void 0;
469
+ const s2 = ls();
470
+ if (!s2) return void 0;
416
471
  try {
417
- const v = s.getItem(KEY.firstTouch);
472
+ const v = s2.getItem(KEY.firstTouch);
418
473
  return v ? JSON.parse(v) : void 0;
419
474
  } catch {
420
475
  return void 0;
421
476
  }
422
477
  }
423
478
  function setFirstTouchOnce(a) {
424
- const s = ls();
479
+ const s2 = ls();
425
480
  const existing = getFirstTouch();
426
481
  if (existing) return existing;
427
- if (s) s.setItem(KEY.firstTouch, JSON.stringify(a));
482
+ if (s2) s2.setItem(KEY.firstTouch, JSON.stringify(a));
428
483
  return a;
429
484
  }
430
485
  function getCohort() {
431
- const s = ls();
432
- if (!s) return void 0;
486
+ const s2 = ls();
487
+ if (!s2) return void 0;
433
488
  try {
434
- const v = s.getItem(KEY.cohort);
489
+ const v = s2.getItem(KEY.cohort);
435
490
  return v ? JSON.parse(v) : void 0;
436
491
  } catch {
437
492
  return void 0;
438
493
  }
439
494
  }
440
495
  function mergeCohort(patch) {
441
- const s = ls();
496
+ const s2 = ls();
442
497
  const cur = getCohort() || {};
443
498
  const next = {
444
499
  signupWeek: cur.signupWeek || patch.signupWeek,
445
500
  channel: patch.channel || cur.channel,
446
501
  refCode: cur.refCode || patch.refCode
447
502
  };
448
- if (s) s.setItem(KEY.cohort, JSON.stringify(next));
503
+ if (s2) s2.setItem(KEY.cohort, JSON.stringify(next));
449
504
  return next;
450
505
  }
451
506
 
@@ -478,15 +533,8 @@ function uid2() {
478
533
  return "m-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
479
534
  }
480
535
  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
- }
536
+ const n = normalizeError(err);
537
+ return { type: n.name, message: n.message, stack: n.stack };
490
538
  }
491
539
  var isBrowser = () => typeof window !== "undefined";
492
540
  function serializeBatch(batch) {
@@ -559,7 +607,7 @@ var Analytics = class {
559
607
  ...config
560
608
  };
561
609
  this.transport = config.transport ?? new DefaultTransport();
562
- this.dsn = parseDsn(config.dsn ?? readEnvDsn());
610
+ this.dsn = parseDsn(config.dsn ?? readEnvDsn() ?? dsnForProduct(this.cfg.product));
563
611
  }
564
612
  /** errorPlaneEnabled reports whether captured exceptions can actually reach the
565
613
  * error host. False means a DSN was never configured — the documented
@@ -773,30 +821,139 @@ function createAnalytics(config) {
773
821
  return new Analytics(config);
774
822
  }
775
823
 
824
+ // src/funnels.ts
825
+ var PRODUCTS = ["site", "app", "chat", "console", "admin", "cloud"];
826
+ var s = (event, label, where) => ({
827
+ event,
828
+ label,
829
+ ...where ? { where } : {}
830
+ });
831
+ var FUNNELS = {
832
+ /** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
833
+ * redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
834
+ signup: {
835
+ label: "Signup",
836
+ products: ["site"],
837
+ join: "person",
838
+ steps: [
839
+ s(PAGEVIEW, "Landed"),
840
+ s(EVENTS.SIGNUP_VIEWED, "Opened signup"),
841
+ s(EVENTS.SIGNUP_SUBMITTED, "Redirected to Hanzo ID"),
842
+ s(EVENTS.SIGNUP_COMPLETED, "Account created"),
843
+ s(EVENTS.FIRST_ACTION, "First action")
844
+ ]
845
+ },
846
+ /** The developer activation path: an account is worth nothing until a key has
847
+ * made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
848
+ * Cloud on an org's first successful /v1 request — a browser cannot see it. */
849
+ apiActivation: {
850
+ label: "API activation",
851
+ products: ["site", "cloud"],
852
+ join: "person",
853
+ steps: [
854
+ s(EVENTS.SIGNUP_COMPLETED, "Account created"),
855
+ s(EVENTS.API_KEY_CREATED, "Key minted"),
856
+ s(EVENTS.FIRST_ACTION, "First successful API call", {
857
+ property: "action",
858
+ equals: "api_call"
859
+ })
860
+ ]
861
+ },
862
+ /** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
863
+ upgrade: {
864
+ label: "Upgrade",
865
+ products: ["site", "app", "console"],
866
+ join: "person",
867
+ steps: [
868
+ s(EVENTS.PRICING_VIEWED, "Viewed pricing"),
869
+ s(EVENTS.PLAN_CLICKED, "Chose a plan"),
870
+ s(EVENTS.CHECKOUT_STARTED, "Started checkout"),
871
+ s(EVENTS.ORDER_COMPLETED, "Paid")
872
+ ]
873
+ },
874
+ /** hanzo.app: describe → build → deploy → live URL. The whole product thesis
875
+ * in five steps; `deploy_succeeded` is the moment a live URL exists. */
876
+ appShip: {
877
+ label: "Describe \u2192 ship",
878
+ products: ["app"],
879
+ join: "person",
880
+ steps: [
881
+ s(PAGEVIEW, "Landed"),
882
+ s(EVENTS.BUILD_STARTED, "Described an app"),
883
+ s(EVENTS.GENERATION_COMPLETED, "Got a working build"),
884
+ s(EVENTS.DEPLOY_STARTED, "Hit publish"),
885
+ s(EVENTS.DEPLOY_SUCCEEDED, "Live URL")
886
+ ]
887
+ },
888
+ /** hanzo.chat: visit → first message → answer. `generation_completed` is what
889
+ * separates "typed something" from "got value". */
890
+ chatEngage: {
891
+ label: "Chat engagement",
892
+ products: ["chat"],
893
+ join: "person",
894
+ steps: [
895
+ s(PAGEVIEW, "Landed"),
896
+ s(EVENTS.CHAT_STARTED, "Started a conversation"),
897
+ s(EVENTS.CHAT_MESSAGE_SENT, "Sent a message"),
898
+ s(EVENTS.GENERATION_COMPLETED, "Got an answer")
899
+ ]
900
+ },
901
+ /** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
902
+ * hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
903
+ * The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
904
+ * which makes the drop-off measurable without any cross-domain identity. */
905
+ siteToChat: {
906
+ label: "Site \u2192 Chat handoff",
907
+ products: ["site", "chat"],
908
+ join: "aggregate",
909
+ steps: [
910
+ s(EVENTS.CHAT_STARTED, "Submitted the hanzo.ai composer", {
911
+ property: "source",
912
+ equals: "composer"
913
+ }),
914
+ s(EVENTS.CHAT_STARTED, "Landed in hanzo.chat", {
915
+ property: "referrerProduct",
916
+ equals: "site"
917
+ }),
918
+ s(EVENTS.GENERATION_COMPLETED, "Got an answer")
919
+ ]
920
+ }
921
+ };
922
+ function eventsOf(id) {
923
+ return FUNNELS[id].steps.map((step) => step.event);
924
+ }
925
+
776
926
  // src/goals.ts
777
927
  var GOALS = {
778
- // Signup: the conversion is signup_completed; the funnel is the four steps.
928
+ // Signup: the conversion is signup_completed, along the site signup funnel.
779
929
  signup: {
780
930
  label: "Signup",
781
931
  event: EVENTS.SIGNUP_COMPLETED,
782
- funnel: [
783
- EVENTS.SIGNUP_VIEWED,
784
- EVENTS.SIGNUP_SUBMITTED,
785
- EVENTS.SIGNUP_VERIFIED,
786
- EVENTS.FIRST_ACTION
787
- ]
932
+ funnelId: "signup",
933
+ funnel: eventsOf("signup")
788
934
  },
789
935
  // Sale: a completed order qualified as a plan purchase (kind=plan).
790
936
  sale: {
791
937
  label: "Sale",
792
938
  event: EVENTS.ORDER_COMPLETED,
939
+ funnelId: "upgrade",
940
+ funnel: eventsOf("upgrade"),
793
941
  filter: { property: "kind", equals: "plan" }
794
942
  },
795
943
  // Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
796
944
  upgradeIntent: {
797
945
  label: "Upgrade Intent",
798
946
  event: EVENTS.PLAN_CLICKED,
799
- funnel: [EVENTS.PRICING_VIEWED, EVENTS.PLAN_CLICKED, EVENTS.CHECKOUT_STARTED]
947
+ funnelId: "upgrade",
948
+ funnel: eventsOf("upgrade")
949
+ },
950
+ // Activation: the ONE north-star conversion — an account that did the first
951
+ // valuable thing (a successful API call, a live app, a chat answer).
952
+ activation: {
953
+ label: "Activation",
954
+ event: EVENTS.FIRST_ACTION,
955
+ funnelId: "apiActivation",
956
+ funnel: eventsOf("apiActivation")
800
957
  }
801
958
  };
802
959
  var COHORTS = {
@@ -808,13 +965,18 @@ var COHORTS = {
808
965
  exports.Analytics = Analytics;
809
966
  exports.COHORTS = COHORTS;
810
967
  exports.EVENTS = EVENTS;
968
+ exports.FUNNELS = FUNNELS;
811
969
  exports.GOALS = GOALS;
812
970
  exports.PAGEVIEW = PAGEVIEW;
971
+ exports.PRODUCTS = PRODUCTS;
972
+ exports.PRODUCT_DSN = PRODUCT_DSN;
813
973
  exports.VERSION = VERSION;
814
974
  exports.buildEnvelope = buildEnvelope;
815
975
  exports.buildSentryEvent = buildSentryEvent;
816
976
  exports.createAnalytics = createAnalytics;
817
977
  exports.deriveChannel = deriveChannel;
978
+ exports.dsnForProduct = dsnForProduct;
979
+ exports.eventsOf = eventsOf;
818
980
  exports.framesFromStack = framesFromStack;
819
981
  exports.getCohort = getCohort;
820
982
  exports.getFirstTouch = getFirstTouch;