@hanzo/event 0.3.1 → 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.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,16 +99,308 @@ 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
 
126
+ // src/scrub.ts
127
+ var REDACTED = "[redacted]";
128
+ var EMAIL_MARK = "[email]";
129
+ var IP_MARK = "[ip]";
130
+ var SECRET_PATTERNS = [
131
+ /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g,
132
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
133
+ // JWT
134
+ /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
135
+ // bearer token
136
+ /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
137
+ // openai-style
138
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
139
+ // stripe
140
+ /\bhk-[A-Za-z0-9]{16,}/g,
141
+ // hanzo key
142
+ /\bAKIA[0-9A-Z]{16}\b/g,
143
+ // aws access key id
144
+ /\bASIA[0-9A-Z]{16}\b/g,
145
+ // aws sts key id
146
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
147
+ // google api key
148
+ /\bgh[posru]_[A-Za-z0-9]{20,}/g,
149
+ // github token
150
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
151
+ // slack token
152
+ // Creds in a URL/DSN. The repetition is BOUNDED on purpose: the unbounded form
153
+ // ([^\s:@/]+:[^\s@/]+@) backtracks quadratically on colon-rich text with no
154
+ // terminating '@' — an HTML error page pasted into an error message took 4.9s
155
+ // at 32KB and >60s at 128KB, freezing the main thread from inside captureError.
156
+ // Real userinfo is far below these caps, so bounding costs nothing.
157
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g
158
+ ];
159
+ var RE_PAN = /\b(?:\d[ -]?){13,19}\b/g;
160
+ function luhn(digits) {
161
+ let sum = 0;
162
+ let alt = false;
163
+ for (let i = digits.length - 1; i >= 0; i--) {
164
+ let d = digits.charCodeAt(i) - 48;
165
+ if (alt) {
166
+ d *= 2;
167
+ if (d > 9) d -= 9;
168
+ }
169
+ sum += d;
170
+ alt = !alt;
171
+ }
172
+ return sum % 10 === 0;
173
+ }
174
+ function redactPAN(s2) {
175
+ return s2.replace(RE_PAN, (m) => {
176
+ const digits = m.replace(/[ -]/g, "");
177
+ if (digits.length < 13 || digits.length > 19) return m;
178
+ return luhn(digits) ? REDACTED : m;
179
+ });
180
+ }
181
+ var RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g;
182
+ var RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
183
+ var RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g;
184
+ function redactSecrets(s2) {
185
+ for (const re of SECRET_PATTERNS) s2 = s2.replace(re, REDACTED);
186
+ return redactPAN(s2);
187
+ }
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;
193
+ }
194
+ var MAX_SCRUB_LEN = 8192;
195
+ function truncate(s2, max = MAX_SCRUB_LEN) {
196
+ return s2.length > max ? s2.slice(0, max) + "\u2026 [truncated]" : s2;
197
+ }
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;
204
+ }
205
+
206
+ // src/version.ts
207
+ var VERSION = "0.3.3";
208
+
209
+ // src/sentry.ts
210
+ var MAX_FRAMES = 50;
211
+ var MAX_LINES = 500;
212
+ var MAX_LINE_LEN = 2048;
213
+ var MAX_TAG_LEN = 1024;
214
+ var MAX_TAGS = 50;
215
+ function eventId() {
216
+ const c = typeof crypto !== "undefined" ? crypto : void 0;
217
+ if (c && "randomUUID" in c) return c.randomUUID().replace(/-/g, "");
218
+ let s2 = "";
219
+ for (let i = 0; i < 32; i++) s2 += Math.floor(Math.random() * 16).toString(16);
220
+ return s2;
221
+ }
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;
226
+ }
227
+ function parseDsn(dsn) {
228
+ if (!dsn) return null;
229
+ const m = /^(https?):\/\/([^@]+)@([^/]+)(\/.*)?$/.exec(dsn.trim());
230
+ if (!m) return null;
231
+ const scheme = m[1];
232
+ const publicKey = m[2];
233
+ const host = m[3];
234
+ const path = m[4] ?? "";
235
+ if (!publicKey || !host) return null;
236
+ const segs = path.split("/").filter(Boolean);
237
+ const projectId = segs.length > 0 ? segs[segs.length - 1] : "";
238
+ if (!projectId) return null;
239
+ const origin = `${scheme}://${host}`;
240
+ const ingestUrl = `${origin}/v1/sentry/${encodeURIComponent(projectId)}/envelope/?sentry_key=${encodeURIComponent(publicKey)}`;
241
+ return { publicKey, origin, projectId, ingestUrl };
242
+ }
243
+ var V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/;
244
+ var MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/;
245
+ function inApp(file) {
246
+ if (!file) return false;
247
+ return !(file.includes("node_modules") || file.startsWith("webpack-internal") || file.startsWith("webpack://") || file.startsWith("chrome-extension://") || file.startsWith("moz-extension://"));
248
+ }
249
+ function framesFromStack(stack) {
250
+ if (!stack) return [];
251
+ const lines = stack.split("\n", MAX_LINES);
252
+ const frames = [];
253
+ for (const raw of lines) {
254
+ if (raw.length > MAX_LINE_LEN) continue;
255
+ const line = raw.trimEnd();
256
+ if (!line) continue;
257
+ let fn;
258
+ let file = "";
259
+ let lineno = 0;
260
+ let colno = 0;
261
+ const v = V8_FRAME.exec(line);
262
+ if (v) {
263
+ fn = v[1];
264
+ if (v[2]) {
265
+ file = v[2];
266
+ lineno = Number(v[3]) || 0;
267
+ colno = Number(v[4]) || 0;
268
+ } else {
269
+ file = (v[5] || "").trim();
270
+ }
271
+ } else {
272
+ const f = MOZ_FRAME.exec(line);
273
+ if (!f) continue;
274
+ fn = f[1];
275
+ file = f[2];
276
+ lineno = Number(f[3]) || 0;
277
+ colno = Number(f[4]) || 0;
278
+ }
279
+ if (!file && !fn) continue;
280
+ frames.push({
281
+ function: fn || "<anonymous>",
282
+ filename: file,
283
+ abs_path: file,
284
+ lineno,
285
+ colno,
286
+ in_app: inApp(file)
287
+ });
288
+ }
289
+ frames.reverse();
290
+ if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES);
291
+ return frames;
292
+ }
293
+ function normalizeError(err) {
294
+ if (err instanceof Error) {
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
+ };
303
+ }
304
+ if (typeof err === "string") return { name: "Error", message: err };
305
+ try {
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];
314
+ } catch {
315
+ return void 0;
316
+ }
317
+ }
318
+ function str(v) {
319
+ try {
320
+ return String(v);
321
+ } catch {
322
+ return "[unstringifiable]";
323
+ }
324
+ }
325
+ function coerceTag(v) {
326
+ const s2 = typeof v === "string" ? v : (() => {
327
+ try {
328
+ return JSON.stringify(v) ?? String(v);
329
+ } catch {
330
+ try {
331
+ return String(v);
332
+ } catch {
333
+ return "[unstringifiable]";
334
+ }
335
+ }
336
+ })();
337
+ return s2.length > MAX_TAG_LEN ? s2.slice(0, MAX_TAG_LEN) : s2;
338
+ }
339
+ function buildSentryEvent(input) {
340
+ const { error, options = {}, identity, capturePII = false } = input;
341
+ const norm = normalizeError(error);
342
+ const handled = options.handled !== false;
343
+ const level = options.level ?? (handled ? "error" : "fatal");
344
+ const tags = { handled: String(handled) };
345
+ if (identity.product) tags.product = identity.product;
346
+ if (identity.sessionId) tags.session = identity.sessionId;
347
+ try {
348
+ const props = options.properties ?? {};
349
+ let n = 0;
350
+ for (const k of Object.keys(props)) {
351
+ if (n >= MAX_TAGS) break;
352
+ try {
353
+ const val = props[k];
354
+ if (val === void 0 || val === null) continue;
355
+ tags[k] = scrubText(coerceTag(val), capturePII);
356
+ n++;
357
+ } catch {
358
+ continue;
359
+ }
360
+ }
361
+ } catch {
362
+ }
363
+ const event = {
364
+ event_id: input.id ?? eventId(),
365
+ timestamp: (input.now ?? Date.now()) / 1e3,
366
+ platform: "javascript",
367
+ level,
368
+ logger: identity.product,
369
+ environment: identity.environment,
370
+ release: identity.release,
371
+ exception: {
372
+ values: [
373
+ {
374
+ type: norm.name,
375
+ value: scrubText(norm.message, capturePII),
376
+ stacktrace: { frames: framesFromStack(norm.stack) }
377
+ }
378
+ ]
379
+ },
380
+ tags,
381
+ sdk: { name: "@hanzo/event", version: VERSION }
382
+ };
383
+ if (identity.userId) event.user = { id: identity.userId };
384
+ return event;
385
+ }
386
+ function buildEnvelope(event, dsn, sentAt) {
387
+ const payload = JSON.stringify(event);
388
+ const header = JSON.stringify({
389
+ event_id: event.event_id,
390
+ dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
391
+ sent_at: sentAt ?? (/* @__PURE__ */ new Date()).toISOString()
392
+ });
393
+ const itemHeader = JSON.stringify({
394
+ type: "event",
395
+ content_type: "application/json",
396
+ length: byteLen(payload)
397
+ });
398
+ return `${header}
399
+ ${itemHeader}
400
+ ${payload}
401
+ `;
402
+ }
403
+
106
404
  // src/storage.ts
107
405
  var KEY = {
108
406
  anon: "hz_anon_id",
@@ -125,21 +423,21 @@ function uid() {
125
423
  return "a-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
126
424
  }
127
425
  function anonId() {
128
- const s = ls();
129
- if (!s) return void 0;
130
- let v = s.getItem(KEY.anon);
426
+ const s2 = ls();
427
+ if (!s2) return void 0;
428
+ let v = s2.getItem(KEY.anon);
131
429
  if (!v) {
132
430
  v = uid();
133
- s.setItem(KEY.anon, v);
431
+ s2.setItem(KEY.anon, v);
134
432
  }
135
433
  return v;
136
434
  }
137
435
  function sessionId(now = Date.now()) {
138
- const s = ls();
139
- if (!s) return void 0;
436
+ const s2 = ls();
437
+ if (!s2) return void 0;
140
438
  let state = null;
141
439
  try {
142
- state = JSON.parse(s.getItem(KEY.session) || "null");
440
+ state = JSON.parse(s2.getItem(KEY.session) || "null");
143
441
  } catch {
144
442
  state = null;
145
443
  }
@@ -148,52 +446,68 @@ function sessionId(now = Date.now()) {
148
446
  } else {
149
447
  state.last = now;
150
448
  }
151
- s.setItem(KEY.session, JSON.stringify(state));
449
+ s2.setItem(KEY.session, JSON.stringify(state));
152
450
  return state.id;
153
451
  }
154
452
  function getFirstTouch() {
155
- const s = ls();
156
- if (!s) return void 0;
453
+ const s2 = ls();
454
+ if (!s2) return void 0;
157
455
  try {
158
- const v = s.getItem(KEY.firstTouch);
456
+ const v = s2.getItem(KEY.firstTouch);
159
457
  return v ? JSON.parse(v) : void 0;
160
458
  } catch {
161
459
  return void 0;
162
460
  }
163
461
  }
164
462
  function setFirstTouchOnce(a) {
165
- const s = ls();
463
+ const s2 = ls();
166
464
  const existing = getFirstTouch();
167
465
  if (existing) return existing;
168
- if (s) s.setItem(KEY.firstTouch, JSON.stringify(a));
466
+ if (s2) s2.setItem(KEY.firstTouch, JSON.stringify(a));
169
467
  return a;
170
468
  }
171
469
  function getCohort() {
172
- const s = ls();
173
- if (!s) return void 0;
470
+ const s2 = ls();
471
+ if (!s2) return void 0;
174
472
  try {
175
- const v = s.getItem(KEY.cohort);
473
+ const v = s2.getItem(KEY.cohort);
176
474
  return v ? JSON.parse(v) : void 0;
177
475
  } catch {
178
476
  return void 0;
179
477
  }
180
478
  }
181
479
  function mergeCohort(patch) {
182
- const s = ls();
480
+ const s2 = ls();
183
481
  const cur = getCohort() || {};
184
482
  const next = {
185
483
  signupWeek: cur.signupWeek || patch.signupWeek,
186
484
  channel: patch.channel || cur.channel,
187
485
  refCode: cur.refCode || patch.refCode
188
486
  };
189
- if (s) s.setItem(KEY.cohort, JSON.stringify(next));
487
+ if (s2) s2.setItem(KEY.cohort, JSON.stringify(next));
190
488
  return next;
191
489
  }
192
490
 
193
491
  // src/core.ts
194
- var VERSION = "0.3.0";
195
492
  var EVENT_PATH = "/v1/event";
196
493
  var DEFAULT_HOST = "https://api.hanzo.ai";
494
+ var ENVELOPE_CONTENT_TYPE = "application/x-sentry-envelope";
495
+ function readEnvDsn() {
496
+ try {
497
+ if (typeof process !== "undefined" && process.env) {
498
+ return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || void 0;
499
+ }
500
+ } catch {
501
+ }
502
+ return void 0;
503
+ }
504
+ function readEnv(name) {
505
+ try {
506
+ if (typeof process !== "undefined" && process.env) return process.env[name] || void 0;
507
+ } catch {
508
+ }
509
+ return void 0;
510
+ }
197
511
  function appendQuery(url, key, value) {
198
512
  return url + (url.includes("?") ? "&" : "?") + key + "=" + encodeURIComponent(value);
199
513
  }
@@ -202,30 +516,42 @@ function uid2() {
202
516
  if (c && "randomUUID" in c) return c.randomUUID();
203
517
  return "m-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
204
518
  }
205
- function normalizeError(err) {
206
- if (err instanceof Error) {
207
- return { type: err.name, message: err.message, stack: err.stack };
208
- }
209
- if (typeof err === "string") return { message: err };
519
+ function normalizeError2(err) {
520
+ const n = normalizeError(err);
521
+ return { type: n.name, message: n.message, stack: n.stack };
522
+ }
523
+ var isBrowser = () => typeof window !== "undefined";
524
+ function serializeBatch(batch) {
210
525
  try {
211
- return { message: JSON.stringify(err) };
526
+ return JSON.stringify({ batch });
212
527
  } catch {
213
- return { message: String(err) };
214
528
  }
529
+ const parts = [];
530
+ for (const e of batch) {
531
+ try {
532
+ parts.push(JSON.stringify(e));
533
+ } catch {
534
+ try {
535
+ parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }));
536
+ } catch {
537
+ }
538
+ }
539
+ }
540
+ return parts.length > 0 ? '{"batch":[' + parts.join(",") + "]}" : null;
215
541
  }
216
- var isBrowser = () => typeof window !== "undefined";
217
542
  var DefaultTransport = class {
218
543
  send(url, body, opts) {
544
+ const contentType = opts.contentType ?? "application/json";
219
545
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === "function") {
220
546
  const beaconUrl = opts.ingestKey ? appendQuery(url, "ingest_key", opts.ingestKey) : url;
221
547
  try {
222
- navigator.sendBeacon(beaconUrl, new Blob([body], { type: "application/json" }));
548
+ navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }));
223
549
  return;
224
550
  } catch {
225
551
  }
226
552
  }
227
553
  if (typeof fetch !== "function") return;
228
- const headers = { "Content-Type": "application/json" };
554
+ const headers = { "Content-Type": contentType };
229
555
  const bearer = opts.ingestKey ?? opts.token;
230
556
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
231
557
  void fetch(url, {
@@ -234,7 +560,12 @@ var DefaultTransport = class {
234
560
  body,
235
561
  keepalive: true,
236
562
  credentials: "include"
237
- }).catch(() => {
563
+ }).then((res) => {
564
+ if (!res.ok && opts.debug) {
565
+ console.warn("[event] ingest rejected", res.status, url.split("?")[0]);
566
+ }
567
+ }).catch((e) => {
568
+ if (opts.debug) console.warn("[event] ingest failed", url.split("?")[0], e);
238
569
  });
239
570
  }
240
571
  };
@@ -245,6 +576,8 @@ var Analytics = class {
245
576
  this.attribution = { utm: {} };
246
577
  this.cohort = {};
247
578
  this.started = false;
579
+ /** Guards against an error thrown *inside* the error path re-entering it. */
580
+ this.reentrant = false;
248
581
  /** track is an alias of capture (Segment familiarity). */
249
582
  this.track = this.capture.bind(this);
250
583
  /** captureException — @sentry-familiar alias of captureError. */
@@ -258,6 +591,19 @@ var Analytics = class {
258
591
  ...config
259
592
  };
260
593
  this.transport = config.transport ?? new DefaultTransport();
594
+ this.dsn = parseDsn(config.dsn ?? readEnvDsn());
595
+ }
596
+ /** errorPlaneEnabled reports whether captured exceptions can actually reach the
597
+ * error host. False means a DSN was never configured — the documented
598
+ * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
599
+ * discovering months later that nothing was ever reported. */
600
+ get errorPlaneEnabled() {
601
+ return this.dsn !== null;
602
+ }
603
+ /** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
604
+ * plane is inert. Diagnostics only. */
605
+ get errorIngestUrl() {
606
+ return this.dsn?.ingestUrl;
261
607
  }
262
608
  /** init is idempotent and browser-only for its side effects: capture first-touch
263
609
  * attribution, hydrate cohort, register the unload flush, and (unless opted out)
@@ -308,18 +654,38 @@ var Analytics = class {
308
654
  capture(event, properties, commerce) {
309
655
  this.enqueue("event", event, { properties, ...commerce });
310
656
  }
311
- /** captureError records an exception as a first-class error event the ONE
312
- * error path (subsumes @sentry). A caught error, an unhandled rejection, a
313
- * React render error, or a manual report all become a type:'error' event on the
314
- * same stream; Cloud folds the exception into properties.$exception and stamps
315
- * event_type='error', so it surfaces in the error-tracking lens. Never throws
316
- * back into the app; errors are higher-signal than pageviews, so it flushes
317
- * promptly (a crash may unload the page moments later). */
657
+ /** captureError reports a caught error, an unhandled rejection, a React render
658
+ * error, or a manual report to BOTH planes, from one call:
659
+ *
660
+ * - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
661
+ * that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
662
+ * Inert when no DSN is configured.
663
+ * - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
664
+ * an error stays correlated with the session's pageviews for product
665
+ * analysis (readable via GET /v1/errors).
666
+ *
667
+ * Both carry the SAME session and subject id, so an error and the pageview
668
+ * before it join up. Never throws back into the app; errors are higher-signal
669
+ * than pageviews, so both planes flush promptly (a crash may unload the page
670
+ * moments later). */
318
671
  captureError(err, context) {
319
- const ex = normalizeError(err);
320
- ex.handled = context?.handled ?? true;
321
- this.enqueue("error", ex.message, { error: ex, properties: context?.properties });
322
- this.flush();
672
+ if (this.reentrant) return;
673
+ this.reentrant = true;
674
+ try {
675
+ try {
676
+ this.sendError(err, context);
677
+ } catch {
678
+ }
679
+ try {
680
+ const ex = normalizeError2(err);
681
+ ex.handled = context?.handled ?? true;
682
+ this.enqueue("error", ex.message, { error: ex, properties: context?.properties });
683
+ this.flush();
684
+ } catch {
685
+ }
686
+ } finally {
687
+ this.reentrant = false;
688
+ }
323
689
  }
324
690
  /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
325
691
  * every subsequent event. */
@@ -345,11 +711,53 @@ var Analytics = class {
345
711
  const key = this.cfg.ingestKey?.trim() || void 0;
346
712
  const token = key ? void 0 : this.cfg.getToken?.() ?? void 0;
347
713
  const useBeacon = beacon && !token;
348
- const body = JSON.stringify({ batch });
714
+ const body = serializeBatch(batch);
715
+ if (body === null) {
716
+ if (this.cfg.debug) console.debug("[event] flush \u2192 dropped, batch unserializable");
717
+ return;
718
+ }
349
719
  if (this.cfg.debug) console.debug("[event] flush \u2192", EVENT_PATH, batch.length);
350
- this.transport.send(this.cfg.host + EVENT_PATH, body, { beacon: useBeacon, token, ingestKey: key });
720
+ this.transport.send(this.cfg.host + EVENT_PATH, body, {
721
+ beacon: useBeacon,
722
+ token,
723
+ ingestKey: key,
724
+ debug: this.cfg.debug
725
+ });
351
726
  }
352
727
  // ── internals ────────────────────────────────────────────────────────────
728
+ /** sendError frames one exception as a Sentry envelope and posts it to the DSN's
729
+ * ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
730
+ * server trusts, and the only one a headerless beacon can carry), so NO bearer
731
+ * or publishable key is attached here — the two planes authenticate
732
+ * independently. Errors are sent one envelope per event, immediately: batching
733
+ * a crash report is how you lose it. */
734
+ sendError(err, options) {
735
+ if (!this.cfg.enabled || !this.dsn) return;
736
+ const event = buildSentryEvent({
737
+ error: err,
738
+ options,
739
+ identity: this.errorIdentity(),
740
+ capturePII: this.cfg.capturePII ?? false
741
+ });
742
+ const body = buildEnvelope(event, this.dsn);
743
+ if (this.cfg.debug) console.debug("[event] error \u2192", this.dsn.ingestUrl, event.event_id);
744
+ this.transport.send(this.dsn.ingestUrl, body, {
745
+ beacon: false,
746
+ contentType: ENVELOPE_CONTENT_TYPE,
747
+ debug: this.cfg.debug
748
+ });
749
+ }
750
+ /** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
751
+ * once identify() has run, else the anon id. Never email/PII. */
752
+ errorIdentity() {
753
+ return {
754
+ userId: this.personId ?? anonId(),
755
+ sessionId: sessionId(),
756
+ product: this.cfg.product,
757
+ release: this.cfg.release ?? readEnv("NEXT_PUBLIC_HANZO_RELEASE"),
758
+ environment: this.cfg.environment ?? readEnv("NODE_ENV")
759
+ };
760
+ }
353
761
  enqueue(kind, event, extra) {
354
762
  if (!this.cfg.enabled) return;
355
763
  if (!this.started) this.init();
@@ -397,30 +805,139 @@ function createAnalytics(config) {
397
805
  return new Analytics(config);
398
806
  }
399
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
+
400
910
  // src/goals.ts
401
911
  var GOALS = {
402
- // Signup: the conversion is signup_completed; the funnel is the four steps.
912
+ // Signup: the conversion is signup_completed, along the site signup funnel.
403
913
  signup: {
404
914
  label: "Signup",
405
915
  event: EVENTS.SIGNUP_COMPLETED,
406
- funnel: [
407
- EVENTS.SIGNUP_VIEWED,
408
- EVENTS.SIGNUP_SUBMITTED,
409
- EVENTS.SIGNUP_VERIFIED,
410
- EVENTS.FIRST_ACTION
411
- ]
916
+ funnelId: "signup",
917
+ funnel: eventsOf("signup")
412
918
  },
413
919
  // Sale: a completed order qualified as a plan purchase (kind=plan).
414
920
  sale: {
415
921
  label: "Sale",
416
922
  event: EVENTS.ORDER_COMPLETED,
923
+ funnelId: "upgrade",
924
+ funnel: eventsOf("upgrade"),
417
925
  filter: { property: "kind", equals: "plan" }
418
926
  },
419
927
  // Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
420
928
  upgradeIntent: {
421
929
  label: "Upgrade Intent",
422
930
  event: EVENTS.PLAN_CLICKED,
423
- 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")
424
941
  }
425
942
  };
426
943
  var COHORTS = {
@@ -429,6 +946,6 @@ var COHORTS = {
429
946
  refCode: { field: "ref_code", label: "Referral code" }
430
947
  };
431
948
 
432
- export { Analytics, COHORTS, EVENTS, GOALS, PAGEVIEW, VERSION, createAnalytics, deriveChannel, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution };
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 };
433
950
  //# sourceMappingURL=index.mjs.map
434
951
  //# sourceMappingURL=index.mjs.map