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