@hanzo/event 0.3.0 → 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.
@@ -105,6 +105,263 @@ var EVENTS = {
105
105
  };
106
106
  var PAGEVIEW = "$pageview";
107
107
 
108
+ // src/scrub.ts
109
+ var REDACTED = "[redacted]";
110
+ var EMAIL_MARK = "[email]";
111
+ var IP_MARK = "[ip]";
112
+ var SECRET_PATTERNS = [
113
+ /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g,
114
+ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
115
+ // JWT
116
+ /\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
117
+ // bearer token
118
+ /\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
119
+ // openai-style
120
+ /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
121
+ // stripe
122
+ /\bhk-[A-Za-z0-9]{16,}/g,
123
+ // hanzo key
124
+ /\bAKIA[0-9A-Z]{16}\b/g,
125
+ // aws access key id
126
+ /\bASIA[0-9A-Z]{16}\b/g,
127
+ // aws sts key id
128
+ /\bAIza[0-9A-Za-z_-]{20,}/g,
129
+ // google api key
130
+ /\bgh[posru]_[A-Za-z0-9]{20,}/g,
131
+ // github token
132
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
133
+ // slack token
134
+ // Creds in a URL/DSN. The repetition is BOUNDED on purpose: the unbounded form
135
+ // ([^\s:@/]+:[^\s@/]+@) backtracks quadratically on colon-rich text with no
136
+ // terminating '@' — an HTML error page pasted into an error message took 4.9s
137
+ // at 32KB and >60s at 128KB, freezing the main thread from inside captureError.
138
+ // Real userinfo is far below these caps, so bounding costs nothing.
139
+ /[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g
140
+ ];
141
+ var RE_PAN = /\b(?:\d[ -]?){13,19}\b/g;
142
+ function luhn(digits) {
143
+ let sum = 0;
144
+ let alt = false;
145
+ for (let i = digits.length - 1; i >= 0; i--) {
146
+ let d = digits.charCodeAt(i) - 48;
147
+ if (alt) {
148
+ d *= 2;
149
+ if (d > 9) d -= 9;
150
+ }
151
+ sum += d;
152
+ alt = !alt;
153
+ }
154
+ return sum % 10 === 0;
155
+ }
156
+ function redactPAN(s) {
157
+ return s.replace(RE_PAN, (m) => {
158
+ const digits = m.replace(/[ -]/g, "");
159
+ if (digits.length < 13 || digits.length > 19) return m;
160
+ return luhn(digits) ? REDACTED : m;
161
+ });
162
+ }
163
+ var RE_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
164
+ var RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
165
+ var RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g;
166
+ function redactSecrets(s) {
167
+ for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED);
168
+ return redactPAN(s);
169
+ }
170
+ function scrubPII(s) {
171
+ s = s.replace(RE_EMAIL, EMAIL_MARK);
172
+ s = s.replace(RE_IPV6, IP_MARK);
173
+ s = s.replace(RE_IPV4, IP_MARK);
174
+ return s;
175
+ }
176
+ var MAX_SCRUB_LEN = 8192;
177
+ function truncate(s, max = MAX_SCRUB_LEN) {
178
+ return s.length > max ? s.slice(0, max) + "\u2026 [truncated]" : s;
179
+ }
180
+ function scrubText(s, capturePII = false) {
181
+ if (!s) return s ?? "";
182
+ s = truncate(s);
183
+ s = redactSecrets(s);
184
+ if (!capturePII) s = scrubPII(s);
185
+ return s;
186
+ }
187
+
188
+ // src/version.ts
189
+ var VERSION = "0.3.2";
190
+
191
+ // src/sentry.ts
192
+ var MAX_FRAMES = 50;
193
+ var MAX_LINES = 500;
194
+ var MAX_LINE_LEN = 2048;
195
+ var MAX_TAG_LEN = 1024;
196
+ var MAX_TAGS = 50;
197
+ function eventId() {
198
+ const c = typeof crypto !== "undefined" ? crypto : void 0;
199
+ if (c && "randomUUID" in c) return c.randomUUID().replace(/-/g, "");
200
+ let s = "";
201
+ for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
202
+ return s;
203
+ }
204
+ function byteLen(s) {
205
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s).length;
206
+ if (typeof Buffer !== "undefined") return Buffer.byteLength(s, "utf8");
207
+ return s.length;
208
+ }
209
+ function parseDsn(dsn) {
210
+ if (!dsn) return null;
211
+ const m = /^(https?):\/\/([^@]+)@([^/]+)(\/.*)?$/.exec(dsn.trim());
212
+ if (!m) return null;
213
+ const scheme = m[1];
214
+ const publicKey = m[2];
215
+ const host = m[3];
216
+ const path = m[4] ?? "";
217
+ if (!publicKey || !host) return null;
218
+ const segs = path.split("/").filter(Boolean);
219
+ const projectId = segs.length > 0 ? segs[segs.length - 1] : "";
220
+ if (!projectId) return null;
221
+ const origin = `${scheme}://${host}`;
222
+ const ingestUrl = `${origin}/v1/sentry/${encodeURIComponent(projectId)}/envelope/?sentry_key=${encodeURIComponent(publicKey)}`;
223
+ return { publicKey, origin, projectId, ingestUrl };
224
+ }
225
+ var V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/;
226
+ var MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/;
227
+ function inApp(file) {
228
+ if (!file) return false;
229
+ return !(file.includes("node_modules") || file.startsWith("webpack-internal") || file.startsWith("webpack://") || file.startsWith("chrome-extension://") || file.startsWith("moz-extension://"));
230
+ }
231
+ function framesFromStack(stack) {
232
+ if (!stack) return [];
233
+ const lines = stack.split("\n", MAX_LINES);
234
+ const frames = [];
235
+ for (const raw of lines) {
236
+ if (raw.length > MAX_LINE_LEN) continue;
237
+ const line = raw.trimEnd();
238
+ if (!line) continue;
239
+ let fn;
240
+ let file = "";
241
+ let lineno = 0;
242
+ let colno = 0;
243
+ const v = V8_FRAME.exec(line);
244
+ if (v) {
245
+ fn = v[1];
246
+ if (v[2]) {
247
+ file = v[2];
248
+ lineno = Number(v[3]) || 0;
249
+ colno = Number(v[4]) || 0;
250
+ } else {
251
+ file = (v[5] || "").trim();
252
+ }
253
+ } else {
254
+ const f = MOZ_FRAME.exec(line);
255
+ if (!f) continue;
256
+ fn = f[1];
257
+ file = f[2];
258
+ lineno = Number(f[3]) || 0;
259
+ colno = Number(f[4]) || 0;
260
+ }
261
+ if (!file && !fn) continue;
262
+ frames.push({
263
+ function: fn || "<anonymous>",
264
+ filename: file,
265
+ abs_path: file,
266
+ lineno,
267
+ colno,
268
+ in_app: inApp(file)
269
+ });
270
+ }
271
+ frames.reverse();
272
+ if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES);
273
+ return frames;
274
+ }
275
+ function normalizeError(err) {
276
+ if (err instanceof Error) {
277
+ return { name: err.name || "Error", message: err.message || String(err), stack: err.stack };
278
+ }
279
+ if (typeof err === "string") return { name: "Error", message: err };
280
+ try {
281
+ return { name: "Error", message: JSON.stringify(err) };
282
+ } catch {
283
+ return { name: "Error", message: String(err) };
284
+ }
285
+ }
286
+ function coerceTag(v) {
287
+ const s = typeof v === "string" ? v : (() => {
288
+ try {
289
+ return JSON.stringify(v) ?? String(v);
290
+ } catch {
291
+ try {
292
+ return String(v);
293
+ } catch {
294
+ return "[unstringifiable]";
295
+ }
296
+ }
297
+ })();
298
+ return s.length > MAX_TAG_LEN ? s.slice(0, MAX_TAG_LEN) : s;
299
+ }
300
+ function buildSentryEvent(input) {
301
+ const { error, options = {}, identity, capturePII = false } = input;
302
+ const norm = normalizeError(error);
303
+ const handled = options.handled !== false;
304
+ const level = options.level ?? (handled ? "error" : "fatal");
305
+ const tags = { handled: String(handled) };
306
+ if (identity.product) tags.product = identity.product;
307
+ if (identity.sessionId) tags.session = identity.sessionId;
308
+ try {
309
+ const props = options.properties ?? {};
310
+ let n = 0;
311
+ for (const k of Object.keys(props)) {
312
+ if (n >= MAX_TAGS) break;
313
+ try {
314
+ const val = props[k];
315
+ if (val === void 0 || val === null) continue;
316
+ tags[k] = scrubText(coerceTag(val), capturePII);
317
+ n++;
318
+ } catch {
319
+ continue;
320
+ }
321
+ }
322
+ } catch {
323
+ }
324
+ const event = {
325
+ event_id: input.id ?? eventId(),
326
+ timestamp: (input.now ?? Date.now()) / 1e3,
327
+ platform: "javascript",
328
+ level,
329
+ logger: identity.product,
330
+ environment: identity.environment,
331
+ release: identity.release,
332
+ exception: {
333
+ values: [
334
+ {
335
+ type: norm.name,
336
+ value: scrubText(norm.message, capturePII),
337
+ stacktrace: { frames: framesFromStack(norm.stack) }
338
+ }
339
+ ]
340
+ },
341
+ tags,
342
+ sdk: { name: "@hanzo/event", version: VERSION }
343
+ };
344
+ if (identity.userId) event.user = { id: identity.userId };
345
+ return event;
346
+ }
347
+ function buildEnvelope(event, dsn, sentAt) {
348
+ const payload = JSON.stringify(event);
349
+ const header = JSON.stringify({
350
+ event_id: event.event_id,
351
+ dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
352
+ sent_at: sentAt ?? (/* @__PURE__ */ new Date()).toISOString()
353
+ });
354
+ const itemHeader = JSON.stringify({
355
+ type: "event",
356
+ content_type: "application/json",
357
+ length: byteLen(payload)
358
+ });
359
+ return `${header}
360
+ ${itemHeader}
361
+ ${payload}
362
+ `;
363
+ }
364
+
108
365
  // src/storage.ts
109
366
  var KEY = {
110
367
  anon: "hz_anon_id",
@@ -193,9 +450,25 @@ function mergeCohort(patch) {
193
450
  }
194
451
 
195
452
  // src/core.ts
196
- var VERSION = "0.3.0";
197
453
  var EVENT_PATH = "/v1/event";
198
454
  var DEFAULT_HOST = "https://api.hanzo.ai";
455
+ var ENVELOPE_CONTENT_TYPE = "application/x-sentry-envelope";
456
+ function readEnvDsn() {
457
+ try {
458
+ if (typeof process !== "undefined" && process.env) {
459
+ return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || void 0;
460
+ }
461
+ } catch {
462
+ }
463
+ return void 0;
464
+ }
465
+ function readEnv(name) {
466
+ try {
467
+ if (typeof process !== "undefined" && process.env) return process.env[name] || void 0;
468
+ } catch {
469
+ }
470
+ return void 0;
471
+ }
199
472
  function appendQuery(url, key, value) {
200
473
  return url + (url.includes("?") ? "&" : "?") + key + "=" + encodeURIComponent(value);
201
474
  }
@@ -204,7 +477,7 @@ function uid2() {
204
477
  if (c && "randomUUID" in c) return c.randomUUID();
205
478
  return "m-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
206
479
  }
207
- function normalizeError(err) {
480
+ function normalizeError2(err) {
208
481
  if (err instanceof Error) {
209
482
  return { type: err.name, message: err.message, stack: err.stack };
210
483
  }
@@ -216,18 +489,37 @@ function normalizeError(err) {
216
489
  }
217
490
  }
218
491
  var isBrowser = () => typeof window !== "undefined";
492
+ function serializeBatch(batch) {
493
+ try {
494
+ return JSON.stringify({ batch });
495
+ } catch {
496
+ }
497
+ const parts = [];
498
+ for (const e of batch) {
499
+ try {
500
+ parts.push(JSON.stringify(e));
501
+ } catch {
502
+ try {
503
+ parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }));
504
+ } catch {
505
+ }
506
+ }
507
+ }
508
+ return parts.length > 0 ? '{"batch":[' + parts.join(",") + "]}" : null;
509
+ }
219
510
  var DefaultTransport = class {
220
511
  send(url, body, opts) {
512
+ const contentType = opts.contentType ?? "application/json";
221
513
  if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === "function") {
222
514
  const beaconUrl = opts.ingestKey ? appendQuery(url, "ingest_key", opts.ingestKey) : url;
223
515
  try {
224
- navigator.sendBeacon(beaconUrl, new Blob([body], { type: "application/json" }));
516
+ navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }));
225
517
  return;
226
518
  } catch {
227
519
  }
228
520
  }
229
521
  if (typeof fetch !== "function") return;
230
- const headers = { "Content-Type": "application/json" };
522
+ const headers = { "Content-Type": contentType };
231
523
  const bearer = opts.ingestKey ?? opts.token;
232
524
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
233
525
  void fetch(url, {
@@ -236,7 +528,12 @@ var DefaultTransport = class {
236
528
  body,
237
529
  keepalive: true,
238
530
  credentials: "include"
239
- }).catch(() => {
531
+ }).then((res) => {
532
+ if (!res.ok && opts.debug) {
533
+ console.warn("[event] ingest rejected", res.status, url.split("?")[0]);
534
+ }
535
+ }).catch((e) => {
536
+ if (opts.debug) console.warn("[event] ingest failed", url.split("?")[0], e);
240
537
  });
241
538
  }
242
539
  };
@@ -247,6 +544,8 @@ var Analytics = class {
247
544
  this.attribution = { utm: {} };
248
545
  this.cohort = {};
249
546
  this.started = false;
547
+ /** Guards against an error thrown *inside* the error path re-entering it. */
548
+ this.reentrant = false;
250
549
  /** track is an alias of capture (Segment familiarity). */
251
550
  this.track = this.capture.bind(this);
252
551
  /** captureException — @sentry-familiar alias of captureError. */
@@ -260,6 +559,19 @@ var Analytics = class {
260
559
  ...config
261
560
  };
262
561
  this.transport = config.transport ?? new DefaultTransport();
562
+ this.dsn = parseDsn(config.dsn ?? readEnvDsn());
563
+ }
564
+ /** errorPlaneEnabled reports whether captured exceptions can actually reach the
565
+ * error host. False means a DSN was never configured — the documented
566
+ * fail-safe. Exposed so an app (or a test) can assert its wiring instead of
567
+ * discovering months later that nothing was ever reported. */
568
+ get errorPlaneEnabled() {
569
+ return this.dsn !== null;
570
+ }
571
+ /** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
572
+ * plane is inert. Diagnostics only. */
573
+ get errorIngestUrl() {
574
+ return this.dsn?.ingestUrl;
263
575
  }
264
576
  /** init is idempotent and browser-only for its side effects: capture first-touch
265
577
  * attribution, hydrate cohort, register the unload flush, and (unless opted out)
@@ -310,18 +622,38 @@ var Analytics = class {
310
622
  capture(event, properties, commerce) {
311
623
  this.enqueue("event", event, { properties, ...commerce });
312
624
  }
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). */
625
+ /** captureError reports a caught error, an unhandled rejection, a React render
626
+ * error, or a manual report to BOTH planes, from one call:
627
+ *
628
+ * - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
629
+ * that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
630
+ * Inert when no DSN is configured.
631
+ * - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
632
+ * an error stays correlated with the session's pageviews for product
633
+ * analysis (readable via GET /v1/errors).
634
+ *
635
+ * Both carry the SAME session and subject id, so an error and the pageview
636
+ * before it join up. Never throws back into the app; errors are higher-signal
637
+ * than pageviews, so both planes flush promptly (a crash may unload the page
638
+ * moments later). */
320
639
  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();
640
+ if (this.reentrant) return;
641
+ this.reentrant = true;
642
+ try {
643
+ try {
644
+ this.sendError(err, context);
645
+ } catch {
646
+ }
647
+ try {
648
+ const ex = normalizeError2(err);
649
+ ex.handled = context?.handled ?? true;
650
+ this.enqueue("error", ex.message, { error: ex, properties: context?.properties });
651
+ this.flush();
652
+ } catch {
653
+ }
654
+ } finally {
655
+ this.reentrant = false;
656
+ }
325
657
  }
326
658
  /** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
327
659
  * every subsequent event. */
@@ -347,11 +679,53 @@ var Analytics = class {
347
679
  const key = this.cfg.ingestKey?.trim() || void 0;
348
680
  const token = key ? void 0 : this.cfg.getToken?.() ?? void 0;
349
681
  const useBeacon = beacon && !token;
350
- const body = JSON.stringify({ batch });
682
+ const body = serializeBatch(batch);
683
+ if (body === null) {
684
+ if (this.cfg.debug) console.debug("[event] flush \u2192 dropped, batch unserializable");
685
+ return;
686
+ }
351
687
  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 });
688
+ this.transport.send(this.cfg.host + EVENT_PATH, body, {
689
+ beacon: useBeacon,
690
+ token,
691
+ ingestKey: key,
692
+ debug: this.cfg.debug
693
+ });
353
694
  }
354
695
  // ── internals ────────────────────────────────────────────────────────────
696
+ /** sendError frames one exception as a Sentry envelope and posts it to the DSN's
697
+ * ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
698
+ * server trusts, and the only one a headerless beacon can carry), so NO bearer
699
+ * or publishable key is attached here — the two planes authenticate
700
+ * independently. Errors are sent one envelope per event, immediately: batching
701
+ * a crash report is how you lose it. */
702
+ sendError(err, options) {
703
+ if (!this.cfg.enabled || !this.dsn) return;
704
+ const event = buildSentryEvent({
705
+ error: err,
706
+ options,
707
+ identity: this.errorIdentity(),
708
+ capturePII: this.cfg.capturePII ?? false
709
+ });
710
+ const body = buildEnvelope(event, this.dsn);
711
+ if (this.cfg.debug) console.debug("[event] error \u2192", this.dsn.ingestUrl, event.event_id);
712
+ this.transport.send(this.dsn.ingestUrl, body, {
713
+ beacon: false,
714
+ contentType: ENVELOPE_CONTENT_TYPE,
715
+ debug: this.cfg.debug
716
+ });
717
+ }
718
+ /** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
719
+ * once identify() has run, else the anon id. Never email/PII. */
720
+ errorIdentity() {
721
+ return {
722
+ userId: this.personId ?? anonId(),
723
+ sessionId: sessionId(),
724
+ product: this.cfg.product,
725
+ release: this.cfg.release ?? readEnv("NEXT_PUBLIC_HANZO_RELEASE"),
726
+ environment: this.cfg.environment ?? readEnv("NODE_ENV")
727
+ };
728
+ }
355
729
  enqueue(kind, event, extra) {
356
730
  if (!this.cfg.enabled) return;
357
731
  if (!this.started) this.init();
@@ -437,13 +811,20 @@ exports.EVENTS = EVENTS;
437
811
  exports.GOALS = GOALS;
438
812
  exports.PAGEVIEW = PAGEVIEW;
439
813
  exports.VERSION = VERSION;
814
+ exports.buildEnvelope = buildEnvelope;
815
+ exports.buildSentryEvent = buildSentryEvent;
440
816
  exports.createAnalytics = createAnalytics;
441
817
  exports.deriveChannel = deriveChannel;
818
+ exports.framesFromStack = framesFromStack;
442
819
  exports.getCohort = getCohort;
443
820
  exports.getFirstTouch = getFirstTouch;
444
821
  exports.hasAttribution = hasAttribution;
445
822
  exports.hostOf = hostOf;
446
823
  exports.isoWeek = isoWeek;
447
824
  exports.parseAttribution = parseAttribution;
448
- //# sourceMappingURL=index.js.map
449
- //# sourceMappingURL=index.js.map
825
+ exports.parseDsn = parseDsn;
826
+ exports.redactSecrets = redactSecrets;
827
+ exports.scrubPII = scrubPII;
828
+ exports.scrubText = scrubText;
829
+ //# sourceMappingURL=index.cjs.map
830
+ //# sourceMappingURL=index.cjs.map