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