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