@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/README.md +81 -17
- package/dist/core-B1XEdWLd.d.cts +296 -0
- package/dist/core-B1XEdWLd.d.ts +296 -0
- package/dist/index.cjs +590 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -6
- package/dist/index.d.ts +177 -6
- package/dist/index.mjs +581 -64
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +413 -25
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +413 -25
- package/dist/react.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core.test.ts +258 -5
- package/src/core.ts +237 -45
- package/src/events.ts +30 -1
- package/src/funnels.test.ts +92 -0
- package/src/funnels.ts +154 -0
- package/src/goals.ts +25 -11
- package/src/index.ts +10 -0
- package/src/scrub.test.ts +96 -0
- package/src/scrub.ts +117 -0
- package/src/sentry.test.ts +312 -0
- package/src/sentry.ts +309 -0
- package/src/types.ts +117 -15
- package/src/version.ts +4 -0
- package/dist/core-CrbiAQhN.d.cts +0 -186
- package/dist/core-CrbiAQhN.d.ts +0 -186
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
|
|
52
|
-
const scheme =
|
|
53
|
-
if (scheme >= 0)
|
|
54
|
-
const cut =
|
|
55
|
-
if (cut >= 0)
|
|
56
|
-
const at =
|
|
57
|
-
if (at >= 0)
|
|
58
|
-
const colon =
|
|
59
|
-
if (colon >= 0)
|
|
60
|
-
return
|
|
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
|
|
131
|
-
if (!
|
|
132
|
-
let v =
|
|
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
|
-
|
|
433
|
+
s2.setItem(KEY.anon, v);
|
|
136
434
|
}
|
|
137
435
|
return v;
|
|
138
436
|
}
|
|
139
437
|
function sessionId(now = Date.now()) {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
438
|
+
const s2 = ls();
|
|
439
|
+
if (!s2) return void 0;
|
|
142
440
|
let state = null;
|
|
143
441
|
try {
|
|
144
|
-
state = JSON.parse(
|
|
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
|
-
|
|
451
|
+
s2.setItem(KEY.session, JSON.stringify(state));
|
|
154
452
|
return state.id;
|
|
155
453
|
}
|
|
156
454
|
function getFirstTouch() {
|
|
157
|
-
const
|
|
158
|
-
if (!
|
|
455
|
+
const s2 = ls();
|
|
456
|
+
if (!s2) return void 0;
|
|
159
457
|
try {
|
|
160
|
-
const v =
|
|
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
|
|
465
|
+
const s2 = ls();
|
|
168
466
|
const existing = getFirstTouch();
|
|
169
467
|
if (existing) return existing;
|
|
170
|
-
if (
|
|
468
|
+
if (s2) s2.setItem(KEY.firstTouch, JSON.stringify(a));
|
|
171
469
|
return a;
|
|
172
470
|
}
|
|
173
471
|
function getCohort() {
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
472
|
+
const s2 = ls();
|
|
473
|
+
if (!s2) return void 0;
|
|
176
474
|
try {
|
|
177
|
-
const v =
|
|
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
|
|
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 (
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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:
|
|
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":
|
|
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
|
-
}).
|
|
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
|
|
314
|
-
* error
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 =
|
|
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, {
|
|
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
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|