@hanzo/event 0.3.1 → 0.3.2

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/react.mjs CHANGED
@@ -70,6 +70,263 @@ function hasAttribution(a) {
70
70
  // src/events.ts
71
71
  var PAGEVIEW = "$pageview";
72
72
 
73
+ // src/scrub.ts
74
+ var REDACTED = "[redacted]";
75
+ var EMAIL_MARK = "[email]";
76
+ var IP_MARK = "[ip]";
77
+ var SECRET_PATTERNS = [
78
+ /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g,
79
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
80
+ // JWT
81
+ /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
82
+ // bearer token
83
+ /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
84
+ // openai-style
85
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
86
+ // stripe
87
+ /\bhk-[A-Za-z0-9]{16,}/g,
88
+ // hanzo key
89
+ /\bAKIA[0-9A-Z]{16}\b/g,
90
+ // aws access key id
91
+ /\bASIA[0-9A-Z]{16}\b/g,
92
+ // aws sts key id
93
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
94
+ // google api key
95
+ /\bgh[posru]_[A-Za-z0-9]{20,}/g,
96
+ // github token
97
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
98
+ // slack token
99
+ // Creds in a URL/DSN. The repetition is BOUNDED on purpose: the unbounded form
100
+ // ([^\s:@/]+:[^\s@/]+@) backtracks quadratically on colon-rich text with no
101
+ // terminating '@' — an HTML error page pasted into an error message took 4.9s
102
+ // at 32KB and >60s at 128KB, freezing the main thread from inside captureError.
103
+ // Real userinfo is far below these caps, so bounding costs nothing.
104
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g
105
+ ];
106
+ var RE_PAN = /\b(?:\d[ -]?){13,19}\b/g;
107
+ function luhn(digits) {
108
+ let sum = 0;
109
+ let alt = false;
110
+ for (let i = digits.length - 1; i >= 0; i--) {
111
+ let d = digits.charCodeAt(i) - 48;
112
+ if (alt) {
113
+ d *= 2;
114
+ if (d > 9) d -= 9;
115
+ }
116
+ sum += d;
117
+ alt = !alt;
118
+ }
119
+ return sum % 10 === 0;
120
+ }
121
+ function redactPAN(s) {
122
+ return s.replace(RE_PAN, (m) => {
123
+ const digits = m.replace(/[ -]/g, "");
124
+ if (digits.length < 13 || digits.length > 19) return m;
125
+ return luhn(digits) ? REDACTED : m;
126
+ });
127
+ }
128
+ var RE_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
129
+ var RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
130
+ var RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g;
131
+ function redactSecrets(s) {
132
+ for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED);
133
+ return redactPAN(s);
134
+ }
135
+ function scrubPII(s) {
136
+ s = s.replace(RE_EMAIL, EMAIL_MARK);
137
+ s = s.replace(RE_IPV6, IP_MARK);
138
+ s = s.replace(RE_IPV4, IP_MARK);
139
+ return s;
140
+ }
141
+ var MAX_SCRUB_LEN = 8192;
142
+ function truncate(s, max = MAX_SCRUB_LEN) {
143
+ return s.length > max ? s.slice(0, max) + "\u2026 [truncated]" : s;
144
+ }
145
+ function scrubText(s, capturePII = false) {
146
+ if (!s) return s ?? "";
147
+ s = truncate(s);
148
+ s = redactSecrets(s);
149
+ if (!capturePII) s = scrubPII(s);
150
+ return s;
151
+ }
152
+
153
+ // src/version.ts
154
+ var VERSION = "0.3.2";
155
+
156
+ // src/sentry.ts
157
+ var MAX_FRAMES = 50;
158
+ var MAX_LINES = 500;
159
+ var MAX_LINE_LEN = 2048;
160
+ var MAX_TAG_LEN = 1024;
161
+ var MAX_TAGS = 50;
162
+ function eventId() {
163
+ const c = typeof crypto !== "undefined" ? crypto : void 0;
164
+ if (c && "randomUUID" in c) return c.randomUUID().replace(/-/g, "");
165
+ let s = "";
166
+ for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
167
+ return s;
168
+ }
169
+ function byteLen(s) {
170
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s).length;
171
+ if (typeof Buffer !== "undefined") return Buffer.byteLength(s, "utf8");
172
+ return s.length;
173
+ }
174
+ function parseDsn(dsn) {
175
+ if (!dsn) return null;
176
+ const m = /^(https?):\/\/([^@]+)@([^/]+)(\/.*)?$/.exec(dsn.trim());
177
+ if (!m) return null;
178
+ const scheme = m[1];
179
+ const publicKey = m[2];
180
+ const host = m[3];
181
+ const path = m[4] ?? "";
182
+ if (!publicKey || !host) return null;
183
+ const segs = path.split("/").filter(Boolean);
184
+ const projectId = segs.length > 0 ? segs[segs.length - 1] : "";
185
+ if (!projectId) return null;
186
+ const origin = `${scheme}://${host}`;
187
+ const ingestUrl = `${origin}/v1/sentry/${encodeURIComponent(projectId)}/envelope/?sentry_key=${encodeURIComponent(publicKey)}`;
188
+ return { publicKey, origin, projectId, ingestUrl };
189
+ }
190
+ var V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/;
191
+ var MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/;
192
+ function inApp(file) {
193
+ if (!file) return false;
194
+ return !(file.includes("node_modules") || file.startsWith("webpack-internal") || file.startsWith("webpack://") || file.startsWith("chrome-extension://") || file.startsWith("moz-extension://"));
195
+ }
196
+ function framesFromStack(stack) {
197
+ if (!stack) return [];
198
+ const lines = stack.split("\n", MAX_LINES);
199
+ const frames = [];
200
+ for (const raw of lines) {
201
+ if (raw.length > MAX_LINE_LEN) continue;
202
+ const line = raw.trimEnd();
203
+ if (!line) continue;
204
+ let fn;
205
+ let file = "";
206
+ let lineno = 0;
207
+ let colno = 0;
208
+ const v = V8_FRAME.exec(line);
209
+ if (v) {
210
+ fn = v[1];
211
+ if (v[2]) {
212
+ file = v[2];
213
+ lineno = Number(v[3]) || 0;
214
+ colno = Number(v[4]) || 0;
215
+ } else {
216
+ file = (v[5] || "").trim();
217
+ }
218
+ } else {
219
+ const f = MOZ_FRAME.exec(line);
220
+ if (!f) continue;
221
+ fn = f[1];
222
+ file = f[2];
223
+ lineno = Number(f[3]) || 0;
224
+ colno = Number(f[4]) || 0;
225
+ }
226
+ if (!file && !fn) continue;
227
+ frames.push({
228
+ function: fn || "<anonymous>",
229
+ filename: file,
230
+ abs_path: file,
231
+ lineno,
232
+ colno,
233
+ in_app: inApp(file)
234
+ });
235
+ }
236
+ frames.reverse();
237
+ if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES);
238
+ return frames;
239
+ }
240
+ function normalizeError(err) {
241
+ if (err instanceof Error) {
242
+ return { name: err.name || "Error", message: err.message || String(err), stack: err.stack };
243
+ }
244
+ if (typeof err === "string") return { name: "Error", message: err };
245
+ try {
246
+ return { name: "Error", message: JSON.stringify(err) };
247
+ } catch {
248
+ return { name: "Error", message: String(err) };
249
+ }
250
+ }
251
+ function coerceTag(v) {
252
+ const s = typeof v === "string" ? v : (() => {
253
+ try {
254
+ return JSON.stringify(v) ?? String(v);
255
+ } catch {
256
+ try {
257
+ return String(v);
258
+ } catch {
259
+ return "[unstringifiable]";
260
+ }
261
+ }
262
+ })();
263
+ return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s;
264
+ }
265
+ function buildSentryEvent(input) {
266
+ const { error, options = {}, identity, capturePII = false } = input;
267
+ const norm = normalizeError(error);
268
+ const handled = options.handled !== false;
269
+ const level = options.level ?? (handled ? "error" : "fatal");
270
+ const tags = { handled: String(handled) };
271
+ if (identity.product) tags.product = identity.product;
272
+ if (identity.sessionId) tags.session = identity.sessionId;
273
+ try {
274
+ const props = options.properties ?? {};
275
+ let n = 0;
276
+ for (const k of Object.keys(props)) {
277
+ if (n >= MAX_TAGS) break;
278
+ try {
279
+ const val = props[k];
280
+ if (val === void 0 || val === null) continue;
281
+ tags[k] = scrubText(coerceTag(val), capturePII);
282
+ n++;
283
+ } catch {
284
+ continue;
285
+ }
286
+ }
287
+ } catch {
288
+ }
289
+ const event = {
290
+ event_id: input.id ?? eventId(),
291
+ timestamp: (input.now ?? Date.now()) / 1e3,
292
+ platform: "javascript",
293
+ level,
294
+ logger: identity.product,
295
+ environment: identity.environment,
296
+ release: identity.release,
297
+ exception: {
298
+ values: [
299
+ {
300
+ type: norm.name,
301
+ value: scrubText(norm.message, capturePII),
302
+ stacktrace: { frames: framesFromStack(norm.stack) }
303
+ }
304
+ ]
305
+ },
306
+ tags,
307
+ sdk: { name: "@hanzo/event", version: VERSION }
308
+ };
309
+ if (identity.userId) event.user = { id: identity.userId };
310
+ return event;
311
+ }
312
+ function buildEnvelope(event, dsn, sentAt) {
313
+ const payload = JSON.stringify(event);
314
+ const header = JSON.stringify({
315
+ event_id: event.event_id,
316
+ dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
317
+ sent_at: (/* @__PURE__ */ new Date()).toISOString()
318
+ });
319
+ const itemHeader = JSON.stringify({
320
+ type: "event",
321
+ content_type: "application/json",
322
+ length: byteLen(payload)
323
+ });
324
+ return `${header}
325
+ ${itemHeader}
326
+ ${payload}
327
+ `;
328
+ }
329
+
73
330
  // src/storage.ts
74
331
  var KEY = {
75
332
  anon: "hz_anon_id",
@@ -158,9 +415,25 @@ function mergeCohort(patch) {
158
415
  }
159
416
 
160
417
  // src/core.ts
161
- var VERSION = "0.3.0";
162
418
  var EVENT_PATH = "/v1/event";
163
419
  var DEFAULT_HOST = "https://api.hanzo.ai";
420
+ var ENVELOPE_CONTENT_TYPE = "application/x-sentry-envelope";
421
+ function readEnvDsn() {
422
+ try {
423
+ if (typeof process !== "undefined" && process.env) {
424
+ return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || void 0;
425
+ }
426
+ } catch {
427
+ }
428
+ return void 0;
429
+ }
430
+ function readEnv(name) {
431
+ try {
432
+ if (typeof process !== "undefined" && process.env) return process.env[name] || void 0;
433
+ } catch {
434
+ }
435
+ return void 0;
436
+ }
164
437
  function appendQuery(url, key, value) {
165
438
  return url + (url.includes("?") ? "&" : "?") + key + "=" + encodeURIComponent(value);
166
439
  }
@@ -169,7 +442,7 @@ function uid2() {
169
442
  if (c && "randomUUID" in c) return c.randomUUID();
170
443
  return "m-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
171
444
  }
172
- function normalizeError(err) {
445
+ function normalizeError2(err) {
173
446
  if (err instanceof Error) {
174
447
  return { type: err.name, message: err.message, stack: err.stack };
175
448
  }
@@ -181,18 +454,37 @@ function normalizeError(err) {
181
454
  }
182
455
  }
183
456
  var isBrowser = () => typeof window !== "undefined";
457
+ function serializeBatch(batch) {
458
+ try {
459
+ return JSON.stringify({ batch });
460
+ } catch {
461
+ }
462
+ const parts = [];
463
+ for (const e of batch) {
464
+ try {
465
+ parts.push(JSON.stringify(e));
466
+ } catch {
467
+ try {
468
+ parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }));
469
+ } catch {
470
+ }
471
+ }
472
+ }
473
+ return parts.length > 0 ? '{"batch":[' + parts.join(",") + "]}" : null;
474
+ }
184
475
  var DefaultTransport = class {
185
476
  send(url, body, opts) {
477
+ const contentType = opts.contentType ?? "application/json";
186
478
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === "function") {
187
479
  const beaconUrl = opts.ingestKey ? appendQuery(url, "ingest_key", opts.ingestKey) : url;
188
480
  try {
189
- navigator.sendBeacon(beaconUrl, new Blob([body], { type: "application/json" }));
481
+ navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }));
190
482
  return;
191
483
  } catch {
192
484
  }
193
485
  }
194
486
  if (typeof fetch !== "function") return;
195
- const headers = { "Content-Type": "application/json" };
487
+ const headers = { "Content-Type": contentType };
196
488
  const bearer = opts.ingestKey ?? opts.token;
197
489
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
198
490
  void fetch(url, {
@@ -201,7 +493,12 @@ var DefaultTransport = class {
201
493
  body,
202
494
  keepalive: true,
203
495
  credentials: "include"
204
- }).catch(() => {
496
+ }).then((res) => {
497
+ if (!res.ok && opts.debug) {
498
+ console.warn("[event] ingest rejected", res.status, url.split("?")[0]);
499
+ }
500
+ }).catch((e) => {
501
+ if (opts.debug) console.warn("[event] ingest failed", url.split("?")[0], e);
205
502
  });
206
503
  }
207
504
  };
@@ -212,6 +509,8 @@ var Analytics = class {
212
509
  this.attribution = { utm: {} };
213
510
  this.cohort = {};
214
511
  this.started = false;
512
+ /** Guards against an error thrown *inside* the error path re-entering it. */
513
+ this.reentrant = false;
215
514
  /** track is an alias of capture (Segment familiarity). */
216
515
  this.track = this.capture.bind(this);
217
516
  /** captureException — @sentry-familiar alias of captureError. */
@@ -225,6 +524,19 @@ var Analytics = class {
225
524
  ...config
226
525
  };
227
526
  this.transport = config.transport ?? new DefaultTransport();
527
+ this.dsn = parseDsn(config.dsn ?? readEnvDsn());
528
+ }
529
+ /** errorPlaneEnabled reports whether captured exceptions can actually reach the
530
+ * error host. False means a DSN was never configured — the documented
531
+ * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
532
+ * discovering months later that nothing was ever reported. */
533
+ get errorPlaneEnabled() {
534
+ return this.dsn !== null;
535
+ }
536
+ /** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
537
+ * plane is inert. Diagnostics only. */
538
+ get errorIngestUrl() {
539
+ return this.dsn?.ingestUrl;
228
540
  }
229
541
  /** init is idempotent and browser-only for its side effects: capture first-touch
230
542
  * attribution, hydrate cohort, register the unload flush, and (unless opted out)
@@ -275,18 +587,38 @@ var Analytics = class {
275
587
  capture(event, properties, commerce) {
276
588
  this.enqueue("event", event, { properties, ...commerce });
277
589
  }
278
- /** captureError records an exception as a first-class error event the ONE
279
- * error path (subsumes @sentry). A caught error, an unhandled rejection, a
280
- * React render error, or a manual report all become a type:'error' event on the
281
- * same stream; Cloud folds the exception into properties.$exception and stamps
282
- * event_type='error', so it surfaces in the error-tracking lens. Never throws
283
- * back into the app; errors are higher-signal than pageviews, so it flushes
284
- * promptly (a crash may unload the page moments later). */
590
+ /** captureError reports a caught error, an unhandled rejection, a React render
591
+ * error, or a manual report to BOTH planes, from one call:
592
+ *
593
+ * - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
594
+ * that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
595
+ * Inert when no DSN is configured.
596
+ * - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
597
+ * an error stays correlated with the session's pageviews for product
598
+ * analysis (readable via GET /v1/errors).
599
+ *
600
+ * Both carry the SAME session and subject id, so an error and the pageview
601
+ * before it join up. Never throws back into the app; errors are higher-signal
602
+ * than pageviews, so both planes flush promptly (a crash may unload the page
603
+ * moments later). */
285
604
  captureError(err, context) {
286
- const ex = normalizeError(err);
287
- ex.handled = context?.handled ?? true;
288
- this.enqueue("error", ex.message, { error: ex, properties: context?.properties });
289
- this.flush();
605
+ if (this.reentrant) return;
606
+ this.reentrant = true;
607
+ try {
608
+ try {
609
+ this.sendError(err, context);
610
+ } catch {
611
+ }
612
+ try {
613
+ const ex = normalizeError2(err);
614
+ ex.handled = context?.handled ?? true;
615
+ this.enqueue("error", ex.message, { error: ex, properties: context?.properties });
616
+ this.flush();
617
+ } catch {
618
+ }
619
+ } finally {
620
+ this.reentrant = false;
621
+ }
290
622
  }
291
623
  /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
292
624
  * every subsequent event. */
@@ -312,11 +644,53 @@ var Analytics = class {
312
644
  const key = this.cfg.ingestKey?.trim() || void 0;
313
645
  const token = key ? void 0 : this.cfg.getToken?.() ?? void 0;
314
646
  const useBeacon = beacon && !token;
315
- const body = JSON.stringify({ batch });
647
+ const body = serializeBatch(batch);
648
+ if (body === null) {
649
+ if (this.cfg.debug) console.debug("[event] flush \u2192 dropped, batch unserializable");
650
+ return;
651
+ }
316
652
  if (this.cfg.debug) console.debug("[event] flush \u2192", EVENT_PATH, batch.length);
317
- this.transport.send(this.cfg.host + EVENT_PATH, body, { beacon: useBeacon, token, ingestKey: key });
653
+ this.transport.send(this.cfg.host + EVENT_PATH, body, {
654
+ beacon: useBeacon,
655
+ token,
656
+ ingestKey: key,
657
+ debug: this.cfg.debug
658
+ });
318
659
  }
319
660
  // ── internals ────────────────────────────────────────────────────────────
661
+ /** sendError frames one exception as a Sentry envelope and posts it to the DSN's
662
+ * ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
663
+ * server trusts, and the only one a headerless beacon can carry), so NO bearer
664
+ * or publishable key is attached here — the two planes authenticate
665
+ * independently. Errors are sent one envelope per event, immediately: batching
666
+ * a crash report is how you lose it. */
667
+ sendError(err, options) {
668
+ if (!this.cfg.enabled || !this.dsn) return;
669
+ const event = buildSentryEvent({
670
+ error: err,
671
+ options,
672
+ identity: this.errorIdentity(),
673
+ capturePII: this.cfg.capturePII ?? false
674
+ });
675
+ const body = buildEnvelope(event, this.dsn);
676
+ if (this.cfg.debug) console.debug("[event] error \u2192", this.dsn.ingestUrl, event.event_id);
677
+ this.transport.send(this.dsn.ingestUrl, body, {
678
+ beacon: false,
679
+ contentType: ENVELOPE_CONTENT_TYPE,
680
+ debug: this.cfg.debug
681
+ });
682
+ }
683
+ /** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
684
+ * once identify() has run, else the anon id. Never email/PII. */
685
+ errorIdentity() {
686
+ return {
687
+ userId: this.personId ?? anonId(),
688
+ sessionId: sessionId(),
689
+ product: this.cfg.product,
690
+ release: this.cfg.release ?? readEnv("NEXT_PUBLIC_HANZO_RELEASE"),
691
+ environment: this.cfg.environment ?? readEnv("NODE_ENV")
692
+ };
693
+ }
320
694
  enqueue(kind, event, extra) {
321
695
  if (!this.cfg.enabled) return;
322
696
  if (!this.started) this.init();