@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.mjs
CHANGED
|
@@ -46,16 +46,16 @@ function deriveChannel(a) {
|
|
|
46
46
|
}
|
|
47
47
|
function hostOf(raw) {
|
|
48
48
|
if (!raw) return "";
|
|
49
|
-
let
|
|
50
|
-
const scheme =
|
|
51
|
-
if (scheme >= 0)
|
|
52
|
-
const cut =
|
|
53
|
-
if (cut >= 0)
|
|
54
|
-
const at =
|
|
55
|
-
if (at >= 0)
|
|
56
|
-
const colon =
|
|
57
|
-
if (colon >= 0)
|
|
58
|
-
return
|
|
49
|
+
let s2 = raw.trim();
|
|
50
|
+
const scheme = s2.indexOf("://");
|
|
51
|
+
if (scheme >= 0) s2 = s2.slice(scheme + 3);
|
|
52
|
+
const cut = s2.search(/[/?#]/);
|
|
53
|
+
if (cut >= 0) s2 = s2.slice(0, cut);
|
|
54
|
+
const at = s2.indexOf("@");
|
|
55
|
+
if (at >= 0) s2 = s2.slice(at + 1);
|
|
56
|
+
const colon = s2.indexOf(":");
|
|
57
|
+
if (colon >= 0) s2 = s2.slice(0, colon);
|
|
58
|
+
return s2.toLowerCase().trim();
|
|
59
59
|
}
|
|
60
60
|
function hasAttribution(a) {
|
|
61
61
|
return Boolean(
|
|
@@ -78,6 +78,12 @@ var EVENTS = {
|
|
|
78
78
|
SIGNUP_SUBMITTED: "signup_submitted",
|
|
79
79
|
SIGNUP_VERIFIED: "signup_verified",
|
|
80
80
|
SIGNUP_COMPLETED: "signup_completed",
|
|
81
|
+
/** A RETURNING user authenticated — the non-signup half of the IAM callback.
|
|
82
|
+
* Keeping it distinct is what stops returning logins from inflating signups. */
|
|
83
|
+
LOGIN_COMPLETED: "login_completed",
|
|
84
|
+
/** Activation: the first moment of real value. ONE event for every product —
|
|
85
|
+
* the product-specific moment is the `action` property (api_call, app_live,
|
|
86
|
+
* chat_reply), never a new event name. */
|
|
81
87
|
FIRST_ACTION: "first_action",
|
|
82
88
|
// Waitlist + referral.
|
|
83
89
|
WAITLIST_JOINED: "waitlist_joined",
|
|
@@ -93,16 +99,308 @@ var EVENTS = {
|
|
|
93
99
|
FEATURE_USED: "feature_used",
|
|
94
100
|
API_KEY_CREATED: "api_key_created",
|
|
95
101
|
APP_CREATED: "app_created",
|
|
96
|
-
DEPLOY_STARTED: "deploy_started",
|
|
97
102
|
PROJECT_CREATED: "project_created",
|
|
98
103
|
AGENT_CREATED: "agent_created",
|
|
99
104
|
CHAT_STARTED: "chat_started",
|
|
100
105
|
CHAT_MESSAGE_SENT: "chat_message_sent",
|
|
106
|
+
/** The user switched model/endpoint — the single strongest quality signal a
|
|
107
|
+
* chat surface emits (a switch usually follows a bad answer). */
|
|
108
|
+
MODEL_SWITCHED: "model_switched",
|
|
101
109
|
TASK_STARTED: "task_started",
|
|
102
|
-
TASK_COMPLETED: "task_completed"
|
|
110
|
+
TASK_COMPLETED: "task_completed",
|
|
111
|
+
// Build → ship. `build_*` is a MODEL producing an artifact; `deploy_*` is that
|
|
112
|
+
// artifact going live. Intent (build_started) is never the same event as the
|
|
113
|
+
// artifact existing (app_created) — conflating them makes the funnel lie.
|
|
114
|
+
BUILD_STARTED: "build_started",
|
|
115
|
+
/** A model finished producing an artifact (an app build, a chat reply, an agent
|
|
116
|
+
* run). Carries `durationMs` — the outcome event owns its own duration, so no
|
|
117
|
+
* paired start event is needed. */
|
|
118
|
+
GENERATION_COMPLETED: "generation_completed",
|
|
119
|
+
GENERATION_FAILED: "generation_failed",
|
|
120
|
+
DEPLOY_STARTED: "deploy_started",
|
|
121
|
+
DEPLOY_SUCCEEDED: "deploy_succeeded",
|
|
122
|
+
DEPLOY_FAILED: "deploy_failed"
|
|
103
123
|
};
|
|
104
124
|
var PAGEVIEW = "$pageview";
|
|
105
125
|
|
|
126
|
+
// src/scrub.ts
|
|
127
|
+
var REDACTED = "[redacted]";
|
|
128
|
+
var EMAIL_MARK = "[email]";
|
|
129
|
+
var IP_MARK = "[ip]";
|
|
130
|
+
var SECRET_PATTERNS = [
|
|
131
|
+
/-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g,
|
|
132
|
+
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
|
|
133
|
+
// JWT
|
|
134
|
+
/\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
135
|
+
// bearer token
|
|
136
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g,
|
|
137
|
+
// openai-style
|
|
138
|
+
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
139
|
+
// stripe
|
|
140
|
+
/\bhk-[A-Za-z0-9]{16,}/g,
|
|
141
|
+
// hanzo key
|
|
142
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
143
|
+
// aws access key id
|
|
144
|
+
/\bASIA[0-9A-Z]{16}\b/g,
|
|
145
|
+
// aws sts key id
|
|
146
|
+
/\bAIza[0-9A-Za-z_-]{20,}/g,
|
|
147
|
+
// google api key
|
|
148
|
+
/\bgh[posru]_[A-Za-z0-9]{20,}/g,
|
|
149
|
+
// github token
|
|
150
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
151
|
+
// slack token
|
|
152
|
+
// Creds in a URL/DSN. The repetition is BOUNDED on purpose: the unbounded form
|
|
153
|
+
// ([^\s:@/]+:[^\s@/]+@) backtracks quadratically on colon-rich text with no
|
|
154
|
+
// terminating '@' — an HTML error page pasted into an error message took 4.9s
|
|
155
|
+
// at 32KB and >60s at 128KB, freezing the main thread from inside captureError.
|
|
156
|
+
// Real userinfo is far below these caps, so bounding costs nothing.
|
|
157
|
+
/[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g
|
|
158
|
+
];
|
|
159
|
+
var RE_PAN = /\b(?:\d[ -]?){13,19}\b/g;
|
|
160
|
+
function luhn(digits) {
|
|
161
|
+
let sum = 0;
|
|
162
|
+
let alt = false;
|
|
163
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
164
|
+
let d = digits.charCodeAt(i) - 48;
|
|
165
|
+
if (alt) {
|
|
166
|
+
d *= 2;
|
|
167
|
+
if (d > 9) d -= 9;
|
|
168
|
+
}
|
|
169
|
+
sum += d;
|
|
170
|
+
alt = !alt;
|
|
171
|
+
}
|
|
172
|
+
return sum % 10 === 0;
|
|
173
|
+
}
|
|
174
|
+
function redactPAN(s2) {
|
|
175
|
+
return s2.replace(RE_PAN, (m) => {
|
|
176
|
+
const digits = m.replace(/[ -]/g, "");
|
|
177
|
+
if (digits.length < 13 || digits.length > 19) return m;
|
|
178
|
+
return luhn(digits) ? REDACTED : m;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
var RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g;
|
|
182
|
+
var RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g;
|
|
183
|
+
var RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g;
|
|
184
|
+
function redactSecrets(s2) {
|
|
185
|
+
for (const re of SECRET_PATTERNS) s2 = s2.replace(re, REDACTED);
|
|
186
|
+
return redactPAN(s2);
|
|
187
|
+
}
|
|
188
|
+
function scrubPII(s2) {
|
|
189
|
+
s2 = s2.replace(RE_EMAIL, EMAIL_MARK);
|
|
190
|
+
s2 = s2.replace(RE_IPV6, IP_MARK);
|
|
191
|
+
s2 = s2.replace(RE_IPV4, IP_MARK);
|
|
192
|
+
return s2;
|
|
193
|
+
}
|
|
194
|
+
var MAX_SCRUB_LEN = 8192;
|
|
195
|
+
function truncate(s2, max = MAX_SCRUB_LEN) {
|
|
196
|
+
return s2.length > max ? s2.slice(0, max) + "\u2026 [truncated]" : s2;
|
|
197
|
+
}
|
|
198
|
+
function scrubText(s2, capturePII = false) {
|
|
199
|
+
if (!s2) return s2 ?? "";
|
|
200
|
+
s2 = truncate(s2);
|
|
201
|
+
s2 = redactSecrets(s2);
|
|
202
|
+
if (!capturePII) s2 = scrubPII(s2);
|
|
203
|
+
return s2;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/version.ts
|
|
207
|
+
var VERSION = "0.3.3";
|
|
208
|
+
|
|
209
|
+
// src/sentry.ts
|
|
210
|
+
var MAX_FRAMES = 50;
|
|
211
|
+
var MAX_LINES = 500;
|
|
212
|
+
var MAX_LINE_LEN = 2048;
|
|
213
|
+
var MAX_TAG_LEN = 1024;
|
|
214
|
+
var MAX_TAGS = 50;
|
|
215
|
+
function eventId() {
|
|
216
|
+
const c = typeof crypto !== "undefined" ? crypto : void 0;
|
|
217
|
+
if (c && "randomUUID" in c) return c.randomUUID().replace(/-/g, "");
|
|
218
|
+
let s2 = "";
|
|
219
|
+
for (let i = 0; i < 32; i++) s2 += Math.floor(Math.random() * 16).toString(16);
|
|
220
|
+
return s2;
|
|
221
|
+
}
|
|
222
|
+
function byteLen(s2) {
|
|
223
|
+
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s2).length;
|
|
224
|
+
if (typeof Buffer !== "undefined") return Buffer.byteLength(s2, "utf8");
|
|
225
|
+
return s2.length;
|
|
226
|
+
}
|
|
227
|
+
function parseDsn(dsn) {
|
|
228
|
+
if (!dsn) return null;
|
|
229
|
+
const m = /^(https?):\/\/([^@]+)@([^/]+)(\/.*)?$/.exec(dsn.trim());
|
|
230
|
+
if (!m) return null;
|
|
231
|
+
const scheme = m[1];
|
|
232
|
+
const publicKey = m[2];
|
|
233
|
+
const host = m[3];
|
|
234
|
+
const path = m[4] ?? "";
|
|
235
|
+
if (!publicKey || !host) return null;
|
|
236
|
+
const segs = path.split("/").filter(Boolean);
|
|
237
|
+
const projectId = segs.length > 0 ? segs[segs.length - 1] : "";
|
|
238
|
+
if (!projectId) return null;
|
|
239
|
+
const origin = `${scheme}://${host}`;
|
|
240
|
+
const ingestUrl = `${origin}/v1/sentry/${encodeURIComponent(projectId)}/envelope/?sentry_key=${encodeURIComponent(publicKey)}`;
|
|
241
|
+
return { publicKey, origin, projectId, ingestUrl };
|
|
242
|
+
}
|
|
243
|
+
var V8_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(?:(.+?):(\d+):(\d+)|([^)]+))\)?\s*$/;
|
|
244
|
+
var MOZ_FRAME = /^\s*(?:(.*?)@)?(.+?):(\d+):(\d+)\s*$/;
|
|
245
|
+
function inApp(file) {
|
|
246
|
+
if (!file) return false;
|
|
247
|
+
return !(file.includes("node_modules") || file.startsWith("webpack-internal") || file.startsWith("webpack://") || file.startsWith("chrome-extension://") || file.startsWith("moz-extension://"));
|
|
248
|
+
}
|
|
249
|
+
function framesFromStack(stack) {
|
|
250
|
+
if (!stack) return [];
|
|
251
|
+
const lines = stack.split("\n", MAX_LINES);
|
|
252
|
+
const frames = [];
|
|
253
|
+
for (const raw of lines) {
|
|
254
|
+
if (raw.length > MAX_LINE_LEN) continue;
|
|
255
|
+
const line = raw.trimEnd();
|
|
256
|
+
if (!line) continue;
|
|
257
|
+
let fn;
|
|
258
|
+
let file = "";
|
|
259
|
+
let lineno = 0;
|
|
260
|
+
let colno = 0;
|
|
261
|
+
const v = V8_FRAME.exec(line);
|
|
262
|
+
if (v) {
|
|
263
|
+
fn = v[1];
|
|
264
|
+
if (v[2]) {
|
|
265
|
+
file = v[2];
|
|
266
|
+
lineno = Number(v[3]) || 0;
|
|
267
|
+
colno = Number(v[4]) || 0;
|
|
268
|
+
} else {
|
|
269
|
+
file = (v[5] || "").trim();
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
const f = MOZ_FRAME.exec(line);
|
|
273
|
+
if (!f) continue;
|
|
274
|
+
fn = f[1];
|
|
275
|
+
file = f[2];
|
|
276
|
+
lineno = Number(f[3]) || 0;
|
|
277
|
+
colno = Number(f[4]) || 0;
|
|
278
|
+
}
|
|
279
|
+
if (!file && !fn) continue;
|
|
280
|
+
frames.push({
|
|
281
|
+
function: fn || "<anonymous>",
|
|
282
|
+
filename: file,
|
|
283
|
+
abs_path: file,
|
|
284
|
+
lineno,
|
|
285
|
+
colno,
|
|
286
|
+
in_app: inApp(file)
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
frames.reverse();
|
|
290
|
+
if (frames.length > MAX_FRAMES) return frames.slice(frames.length - MAX_FRAMES);
|
|
291
|
+
return frames;
|
|
292
|
+
}
|
|
293
|
+
function normalizeError(err) {
|
|
294
|
+
if (err instanceof Error) {
|
|
295
|
+
const name = read(err, "name");
|
|
296
|
+
const message = read(err, "message");
|
|
297
|
+
const stack = read(err, "stack");
|
|
298
|
+
return {
|
|
299
|
+
name: typeof name === "string" && name ? name : "Error",
|
|
300
|
+
message: typeof message === "string" && message ? message : str(err),
|
|
301
|
+
stack: typeof stack === "string" ? stack : void 0
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (typeof err === "string") return { name: "Error", message: err };
|
|
305
|
+
try {
|
|
306
|
+
return { name: "Error", message: JSON.stringify(err) ?? str(err) };
|
|
307
|
+
} catch {
|
|
308
|
+
return { name: "Error", message: str(err) };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function read(o, k) {
|
|
312
|
+
try {
|
|
313
|
+
return o[k];
|
|
314
|
+
} catch {
|
|
315
|
+
return void 0;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function str(v) {
|
|
319
|
+
try {
|
|
320
|
+
return String(v);
|
|
321
|
+
} catch {
|
|
322
|
+
return "[unstringifiable]";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function coerceTag(v) {
|
|
326
|
+
const s2 = typeof v === "string" ? v : (() => {
|
|
327
|
+
try {
|
|
328
|
+
return JSON.stringify(v) ?? String(v);
|
|
329
|
+
} catch {
|
|
330
|
+
try {
|
|
331
|
+
return String(v);
|
|
332
|
+
} catch {
|
|
333
|
+
return "[unstringifiable]";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
})();
|
|
337
|
+
return s2.length > MAX_TAG_LEN ? s2.slice(0, MAX_TAG_LEN) : s2;
|
|
338
|
+
}
|
|
339
|
+
function buildSentryEvent(input) {
|
|
340
|
+
const { error, options = {}, identity, capturePII = false } = input;
|
|
341
|
+
const norm = normalizeError(error);
|
|
342
|
+
const handled = options.handled !== false;
|
|
343
|
+
const level = options.level ?? (handled ? "error" : "fatal");
|
|
344
|
+
const tags = { handled: String(handled) };
|
|
345
|
+
if (identity.product) tags.product = identity.product;
|
|
346
|
+
if (identity.sessionId) tags.session = identity.sessionId;
|
|
347
|
+
try {
|
|
348
|
+
const props = options.properties ?? {};
|
|
349
|
+
let n = 0;
|
|
350
|
+
for (const k of Object.keys(props)) {
|
|
351
|
+
if (n >= MAX_TAGS) break;
|
|
352
|
+
try {
|
|
353
|
+
const val = props[k];
|
|
354
|
+
if (val === void 0 || val === null) continue;
|
|
355
|
+
tags[k] = scrubText(coerceTag(val), capturePII);
|
|
356
|
+
n++;
|
|
357
|
+
} catch {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
363
|
+
const event = {
|
|
364
|
+
event_id: input.id ?? eventId(),
|
|
365
|
+
timestamp: (input.now ?? Date.now()) / 1e3,
|
|
366
|
+
platform: "javascript",
|
|
367
|
+
level,
|
|
368
|
+
logger: identity.product,
|
|
369
|
+
environment: identity.environment,
|
|
370
|
+
release: identity.release,
|
|
371
|
+
exception: {
|
|
372
|
+
values: [
|
|
373
|
+
{
|
|
374
|
+
type: norm.name,
|
|
375
|
+
value: scrubText(norm.message, capturePII),
|
|
376
|
+
stacktrace: { frames: framesFromStack(norm.stack) }
|
|
377
|
+
}
|
|
378
|
+
]
|
|
379
|
+
},
|
|
380
|
+
tags,
|
|
381
|
+
sdk: { name: "@hanzo/event", version: VERSION }
|
|
382
|
+
};
|
|
383
|
+
if (identity.userId) event.user = { id: identity.userId };
|
|
384
|
+
return event;
|
|
385
|
+
}
|
|
386
|
+
function buildEnvelope(event, dsn, sentAt) {
|
|
387
|
+
const payload = JSON.stringify(event);
|
|
388
|
+
const header = JSON.stringify({
|
|
389
|
+
event_id: event.event_id,
|
|
390
|
+
dsn: `${dsn.origin}/v1/sentry/${dsn.projectId}`,
|
|
391
|
+
sent_at: sentAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
392
|
+
});
|
|
393
|
+
const itemHeader = JSON.stringify({
|
|
394
|
+
type: "event",
|
|
395
|
+
content_type: "application/json",
|
|
396
|
+
length: byteLen(payload)
|
|
397
|
+
});
|
|
398
|
+
return `${header}
|
|
399
|
+
${itemHeader}
|
|
400
|
+
${payload}
|
|
401
|
+
`;
|
|
402
|
+
}
|
|
403
|
+
|
|
106
404
|
// src/storage.ts
|
|
107
405
|
var KEY = {
|
|
108
406
|
anon: "hz_anon_id",
|
|
@@ -125,21 +423,21 @@ function uid() {
|
|
|
125
423
|
return "a-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
126
424
|
}
|
|
127
425
|
function anonId() {
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
130
|
-
let v =
|
|
426
|
+
const s2 = ls();
|
|
427
|
+
if (!s2) return void 0;
|
|
428
|
+
let v = s2.getItem(KEY.anon);
|
|
131
429
|
if (!v) {
|
|
132
430
|
v = uid();
|
|
133
|
-
|
|
431
|
+
s2.setItem(KEY.anon, v);
|
|
134
432
|
}
|
|
135
433
|
return v;
|
|
136
434
|
}
|
|
137
435
|
function sessionId(now = Date.now()) {
|
|
138
|
-
const
|
|
139
|
-
if (!
|
|
436
|
+
const s2 = ls();
|
|
437
|
+
if (!s2) return void 0;
|
|
140
438
|
let state = null;
|
|
141
439
|
try {
|
|
142
|
-
state = JSON.parse(
|
|
440
|
+
state = JSON.parse(s2.getItem(KEY.session) || "null");
|
|
143
441
|
} catch {
|
|
144
442
|
state = null;
|
|
145
443
|
}
|
|
@@ -148,52 +446,68 @@ function sessionId(now = Date.now()) {
|
|
|
148
446
|
} else {
|
|
149
447
|
state.last = now;
|
|
150
448
|
}
|
|
151
|
-
|
|
449
|
+
s2.setItem(KEY.session, JSON.stringify(state));
|
|
152
450
|
return state.id;
|
|
153
451
|
}
|
|
154
452
|
function getFirstTouch() {
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
453
|
+
const s2 = ls();
|
|
454
|
+
if (!s2) return void 0;
|
|
157
455
|
try {
|
|
158
|
-
const v =
|
|
456
|
+
const v = s2.getItem(KEY.firstTouch);
|
|
159
457
|
return v ? JSON.parse(v) : void 0;
|
|
160
458
|
} catch {
|
|
161
459
|
return void 0;
|
|
162
460
|
}
|
|
163
461
|
}
|
|
164
462
|
function setFirstTouchOnce(a) {
|
|
165
|
-
const
|
|
463
|
+
const s2 = ls();
|
|
166
464
|
const existing = getFirstTouch();
|
|
167
465
|
if (existing) return existing;
|
|
168
|
-
if (
|
|
466
|
+
if (s2) s2.setItem(KEY.firstTouch, JSON.stringify(a));
|
|
169
467
|
return a;
|
|
170
468
|
}
|
|
171
469
|
function getCohort() {
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
470
|
+
const s2 = ls();
|
|
471
|
+
if (!s2) return void 0;
|
|
174
472
|
try {
|
|
175
|
-
const v =
|
|
473
|
+
const v = s2.getItem(KEY.cohort);
|
|
176
474
|
return v ? JSON.parse(v) : void 0;
|
|
177
475
|
} catch {
|
|
178
476
|
return void 0;
|
|
179
477
|
}
|
|
180
478
|
}
|
|
181
479
|
function mergeCohort(patch) {
|
|
182
|
-
const
|
|
480
|
+
const s2 = ls();
|
|
183
481
|
const cur = getCohort() || {};
|
|
184
482
|
const next = {
|
|
185
483
|
signupWeek: cur.signupWeek || patch.signupWeek,
|
|
186
484
|
channel: patch.channel || cur.channel,
|
|
187
485
|
refCode: cur.refCode || patch.refCode
|
|
188
486
|
};
|
|
189
|
-
if (
|
|
487
|
+
if (s2) s2.setItem(KEY.cohort, JSON.stringify(next));
|
|
190
488
|
return next;
|
|
191
489
|
}
|
|
192
490
|
|
|
193
491
|
// src/core.ts
|
|
194
|
-
var VERSION = "0.3.0";
|
|
195
492
|
var EVENT_PATH = "/v1/event";
|
|
196
493
|
var DEFAULT_HOST = "https://api.hanzo.ai";
|
|
494
|
+
var ENVELOPE_CONTENT_TYPE = "application/x-sentry-envelope";
|
|
495
|
+
function readEnvDsn() {
|
|
496
|
+
try {
|
|
497
|
+
if (typeof process !== "undefined" && process.env) {
|
|
498
|
+
return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || void 0;
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
return void 0;
|
|
503
|
+
}
|
|
504
|
+
function readEnv(name) {
|
|
505
|
+
try {
|
|
506
|
+
if (typeof process !== "undefined" && process.env) return process.env[name] || void 0;
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
509
|
+
return void 0;
|
|
510
|
+
}
|
|
197
511
|
function appendQuery(url, key, value) {
|
|
198
512
|
return url + (url.includes("?") ? "&" : "?") + key + "=" + encodeURIComponent(value);
|
|
199
513
|
}
|
|
@@ -202,30 +516,42 @@ function uid2() {
|
|
|
202
516
|
if (c && "randomUUID" in c) return c.randomUUID();
|
|
203
517
|
return "m-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
|
204
518
|
}
|
|
205
|
-
function
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
519
|
+
function normalizeError2(err) {
|
|
520
|
+
const n = normalizeError(err);
|
|
521
|
+
return { type: n.name, message: n.message, stack: n.stack };
|
|
522
|
+
}
|
|
523
|
+
var isBrowser = () => typeof window !== "undefined";
|
|
524
|
+
function serializeBatch(batch) {
|
|
210
525
|
try {
|
|
211
|
-
return
|
|
526
|
+
return JSON.stringify({ batch });
|
|
212
527
|
} catch {
|
|
213
|
-
return { message: String(err) };
|
|
214
528
|
}
|
|
529
|
+
const parts = [];
|
|
530
|
+
for (const e of batch) {
|
|
531
|
+
try {
|
|
532
|
+
parts.push(JSON.stringify(e));
|
|
533
|
+
} catch {
|
|
534
|
+
try {
|
|
535
|
+
parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }));
|
|
536
|
+
} catch {
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return parts.length > 0 ? '{"batch":[' + parts.join(",") + "]}" : null;
|
|
215
541
|
}
|
|
216
|
-
var isBrowser = () => typeof window !== "undefined";
|
|
217
542
|
var DefaultTransport = class {
|
|
218
543
|
send(url, body, opts) {
|
|
544
|
+
const contentType = opts.contentType ?? "application/json";
|
|
219
545
|
if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === "function") {
|
|
220
546
|
const beaconUrl = opts.ingestKey ? appendQuery(url, "ingest_key", opts.ingestKey) : url;
|
|
221
547
|
try {
|
|
222
|
-
navigator.sendBeacon(beaconUrl, new Blob([body], { type:
|
|
548
|
+
navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }));
|
|
223
549
|
return;
|
|
224
550
|
} catch {
|
|
225
551
|
}
|
|
226
552
|
}
|
|
227
553
|
if (typeof fetch !== "function") return;
|
|
228
|
-
const headers = { "Content-Type":
|
|
554
|
+
const headers = { "Content-Type": contentType };
|
|
229
555
|
const bearer = opts.ingestKey ?? opts.token;
|
|
230
556
|
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
231
557
|
void fetch(url, {
|
|
@@ -234,7 +560,12 @@ var DefaultTransport = class {
|
|
|
234
560
|
body,
|
|
235
561
|
keepalive: true,
|
|
236
562
|
credentials: "include"
|
|
237
|
-
}).
|
|
563
|
+
}).then((res) => {
|
|
564
|
+
if (!res.ok && opts.debug) {
|
|
565
|
+
console.warn("[event] ingest rejected", res.status, url.split("?")[0]);
|
|
566
|
+
}
|
|
567
|
+
}).catch((e) => {
|
|
568
|
+
if (opts.debug) console.warn("[event] ingest failed", url.split("?")[0], e);
|
|
238
569
|
});
|
|
239
570
|
}
|
|
240
571
|
};
|
|
@@ -245,6 +576,8 @@ var Analytics = class {
|
|
|
245
576
|
this.attribution = { utm: {} };
|
|
246
577
|
this.cohort = {};
|
|
247
578
|
this.started = false;
|
|
579
|
+
/** Guards against an error thrown *inside* the error path re-entering it. */
|
|
580
|
+
this.reentrant = false;
|
|
248
581
|
/** track is an alias of capture (Segment familiarity). */
|
|
249
582
|
this.track = this.capture.bind(this);
|
|
250
583
|
/** captureException — @sentry-familiar alias of captureError. */
|
|
@@ -258,6 +591,19 @@ var Analytics = class {
|
|
|
258
591
|
...config
|
|
259
592
|
};
|
|
260
593
|
this.transport = config.transport ?? new DefaultTransport();
|
|
594
|
+
this.dsn = parseDsn(config.dsn ?? readEnvDsn());
|
|
595
|
+
}
|
|
596
|
+
/** errorPlaneEnabled reports whether captured exceptions can actually reach the
|
|
597
|
+
* error host. False means a DSN was never configured — the documented
|
|
598
|
+
* fail-safe. Exposed so an app (or a test) can assert its wiring instead of
|
|
599
|
+
* discovering months later that nothing was ever reported. */
|
|
600
|
+
get errorPlaneEnabled() {
|
|
601
|
+
return this.dsn !== null;
|
|
602
|
+
}
|
|
603
|
+
/** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
|
|
604
|
+
* plane is inert. Diagnostics only. */
|
|
605
|
+
get errorIngestUrl() {
|
|
606
|
+
return this.dsn?.ingestUrl;
|
|
261
607
|
}
|
|
262
608
|
/** init is idempotent and browser-only for its side effects: capture first-touch
|
|
263
609
|
* attribution, hydrate cohort, register the unload flush, and (unless opted out)
|
|
@@ -308,18 +654,38 @@ var Analytics = class {
|
|
|
308
654
|
capture(event, properties, commerce) {
|
|
309
655
|
this.enqueue("event", event, { properties, ...commerce });
|
|
310
656
|
}
|
|
311
|
-
/** captureError
|
|
312
|
-
* error
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
657
|
+
/** captureError reports a caught error, an unhandled rejection, a React render
|
|
658
|
+
* error, or a manual report to BOTH planes, from one call:
|
|
659
|
+
*
|
|
660
|
+
* - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
|
|
661
|
+
* that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
|
|
662
|
+
* Inert when no DSN is configured.
|
|
663
|
+
* - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
|
|
664
|
+
* an error stays correlated with the session's pageviews for product
|
|
665
|
+
* analysis (readable via GET /v1/errors).
|
|
666
|
+
*
|
|
667
|
+
* Both carry the SAME session and subject id, so an error and the pageview
|
|
668
|
+
* before it join up. Never throws back into the app; errors are higher-signal
|
|
669
|
+
* than pageviews, so both planes flush promptly (a crash may unload the page
|
|
670
|
+
* moments later). */
|
|
318
671
|
captureError(err, context) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
672
|
+
if (this.reentrant) return;
|
|
673
|
+
this.reentrant = true;
|
|
674
|
+
try {
|
|
675
|
+
try {
|
|
676
|
+
this.sendError(err, context);
|
|
677
|
+
} catch {
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const ex = normalizeError2(err);
|
|
681
|
+
ex.handled = context?.handled ?? true;
|
|
682
|
+
this.enqueue("error", ex.message, { error: ex, properties: context?.properties });
|
|
683
|
+
this.flush();
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
} finally {
|
|
687
|
+
this.reentrant = false;
|
|
688
|
+
}
|
|
323
689
|
}
|
|
324
690
|
/** setCohort persists cohort dimensions (e.g. signupWeek at signup) so they ride
|
|
325
691
|
* every subsequent event. */
|
|
@@ -345,11 +711,53 @@ var Analytics = class {
|
|
|
345
711
|
const key = this.cfg.ingestKey?.trim() || void 0;
|
|
346
712
|
const token = key ? void 0 : this.cfg.getToken?.() ?? void 0;
|
|
347
713
|
const useBeacon = beacon && !token;
|
|
348
|
-
const body =
|
|
714
|
+
const body = serializeBatch(batch);
|
|
715
|
+
if (body === null) {
|
|
716
|
+
if (this.cfg.debug) console.debug("[event] flush \u2192 dropped, batch unserializable");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
349
719
|
if (this.cfg.debug) console.debug("[event] flush \u2192", EVENT_PATH, batch.length);
|
|
350
|
-
this.transport.send(this.cfg.host + EVENT_PATH, body, {
|
|
720
|
+
this.transport.send(this.cfg.host + EVENT_PATH, body, {
|
|
721
|
+
beacon: useBeacon,
|
|
722
|
+
token,
|
|
723
|
+
ingestKey: key,
|
|
724
|
+
debug: this.cfg.debug
|
|
725
|
+
});
|
|
351
726
|
}
|
|
352
727
|
// ── internals ────────────────────────────────────────────────────────────
|
|
728
|
+
/** sendError frames one exception as a Sentry envelope and posts it to the DSN's
|
|
729
|
+
* ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
|
|
730
|
+
* server trusts, and the only one a headerless beacon can carry), so NO bearer
|
|
731
|
+
* or publishable key is attached here — the two planes authenticate
|
|
732
|
+
* independently. Errors are sent one envelope per event, immediately: batching
|
|
733
|
+
* a crash report is how you lose it. */
|
|
734
|
+
sendError(err, options) {
|
|
735
|
+
if (!this.cfg.enabled || !this.dsn) return;
|
|
736
|
+
const event = buildSentryEvent({
|
|
737
|
+
error: err,
|
|
738
|
+
options,
|
|
739
|
+
identity: this.errorIdentity(),
|
|
740
|
+
capturePII: this.cfg.capturePII ?? false
|
|
741
|
+
});
|
|
742
|
+
const body = buildEnvelope(event, this.dsn);
|
|
743
|
+
if (this.cfg.debug) console.debug("[event] error \u2192", this.dsn.ingestUrl, event.event_id);
|
|
744
|
+
this.transport.send(this.dsn.ingestUrl, body, {
|
|
745
|
+
beacon: false,
|
|
746
|
+
contentType: ENVELOPE_CONTENT_TYPE,
|
|
747
|
+
debug: this.cfg.debug
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
|
|
751
|
+
* once identify() has run, else the anon id. Never email/PII. */
|
|
752
|
+
errorIdentity() {
|
|
753
|
+
return {
|
|
754
|
+
userId: this.personId ?? anonId(),
|
|
755
|
+
sessionId: sessionId(),
|
|
756
|
+
product: this.cfg.product,
|
|
757
|
+
release: this.cfg.release ?? readEnv("NEXT_PUBLIC_HANZO_RELEASE"),
|
|
758
|
+
environment: this.cfg.environment ?? readEnv("NODE_ENV")
|
|
759
|
+
};
|
|
760
|
+
}
|
|
353
761
|
enqueue(kind, event, extra) {
|
|
354
762
|
if (!this.cfg.enabled) return;
|
|
355
763
|
if (!this.started) this.init();
|
|
@@ -397,30 +805,139 @@ function createAnalytics(config) {
|
|
|
397
805
|
return new Analytics(config);
|
|
398
806
|
}
|
|
399
807
|
|
|
808
|
+
// src/funnels.ts
|
|
809
|
+
var PRODUCTS = ["site", "app", "chat", "console", "admin", "cloud"];
|
|
810
|
+
var s = (event, label, where) => ({
|
|
811
|
+
event,
|
|
812
|
+
label,
|
|
813
|
+
...where ? { where } : {}
|
|
814
|
+
});
|
|
815
|
+
var FUNNELS = {
|
|
816
|
+
/** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
|
|
817
|
+
* redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
|
|
818
|
+
signup: {
|
|
819
|
+
label: "Signup",
|
|
820
|
+
products: ["site"],
|
|
821
|
+
join: "person",
|
|
822
|
+
steps: [
|
|
823
|
+
s(PAGEVIEW, "Landed"),
|
|
824
|
+
s(EVENTS.SIGNUP_VIEWED, "Opened signup"),
|
|
825
|
+
s(EVENTS.SIGNUP_SUBMITTED, "Redirected to Hanzo ID"),
|
|
826
|
+
s(EVENTS.SIGNUP_COMPLETED, "Account created"),
|
|
827
|
+
s(EVENTS.FIRST_ACTION, "First action")
|
|
828
|
+
]
|
|
829
|
+
},
|
|
830
|
+
/** The developer activation path: an account is worth nothing until a key has
|
|
831
|
+
* made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
|
|
832
|
+
* Cloud on an org's first successful /v1 request — a browser cannot see it. */
|
|
833
|
+
apiActivation: {
|
|
834
|
+
label: "API activation",
|
|
835
|
+
products: ["site", "cloud"],
|
|
836
|
+
join: "person",
|
|
837
|
+
steps: [
|
|
838
|
+
s(EVENTS.SIGNUP_COMPLETED, "Account created"),
|
|
839
|
+
s(EVENTS.API_KEY_CREATED, "Key minted"),
|
|
840
|
+
s(EVENTS.FIRST_ACTION, "First successful API call", {
|
|
841
|
+
property: "action",
|
|
842
|
+
equals: "api_call"
|
|
843
|
+
})
|
|
844
|
+
]
|
|
845
|
+
},
|
|
846
|
+
/** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
|
|
847
|
+
upgrade: {
|
|
848
|
+
label: "Upgrade",
|
|
849
|
+
products: ["site", "app", "console"],
|
|
850
|
+
join: "person",
|
|
851
|
+
steps: [
|
|
852
|
+
s(EVENTS.PRICING_VIEWED, "Viewed pricing"),
|
|
853
|
+
s(EVENTS.PLAN_CLICKED, "Chose a plan"),
|
|
854
|
+
s(EVENTS.CHECKOUT_STARTED, "Started checkout"),
|
|
855
|
+
s(EVENTS.ORDER_COMPLETED, "Paid")
|
|
856
|
+
]
|
|
857
|
+
},
|
|
858
|
+
/** hanzo.app: describe → build → deploy → live URL. The whole product thesis
|
|
859
|
+
* in five steps; `deploy_succeeded` is the moment a live URL exists. */
|
|
860
|
+
appShip: {
|
|
861
|
+
label: "Describe \u2192 ship",
|
|
862
|
+
products: ["app"],
|
|
863
|
+
join: "person",
|
|
864
|
+
steps: [
|
|
865
|
+
s(PAGEVIEW, "Landed"),
|
|
866
|
+
s(EVENTS.BUILD_STARTED, "Described an app"),
|
|
867
|
+
s(EVENTS.GENERATION_COMPLETED, "Got a working build"),
|
|
868
|
+
s(EVENTS.DEPLOY_STARTED, "Hit publish"),
|
|
869
|
+
s(EVENTS.DEPLOY_SUCCEEDED, "Live URL")
|
|
870
|
+
]
|
|
871
|
+
},
|
|
872
|
+
/** hanzo.chat: visit → first message → answer. `generation_completed` is what
|
|
873
|
+
* separates "typed something" from "got value". */
|
|
874
|
+
chatEngage: {
|
|
875
|
+
label: "Chat engagement",
|
|
876
|
+
products: ["chat"],
|
|
877
|
+
join: "person",
|
|
878
|
+
steps: [
|
|
879
|
+
s(PAGEVIEW, "Landed"),
|
|
880
|
+
s(EVENTS.CHAT_STARTED, "Started a conversation"),
|
|
881
|
+
s(EVENTS.CHAT_MESSAGE_SENT, "Sent a message"),
|
|
882
|
+
s(EVENTS.GENERATION_COMPLETED, "Got an answer")
|
|
883
|
+
]
|
|
884
|
+
},
|
|
885
|
+
/** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
|
|
886
|
+
* hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
|
|
887
|
+
* The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
|
|
888
|
+
* which makes the drop-off measurable without any cross-domain identity. */
|
|
889
|
+
siteToChat: {
|
|
890
|
+
label: "Site \u2192 Chat handoff",
|
|
891
|
+
products: ["site", "chat"],
|
|
892
|
+
join: "aggregate",
|
|
893
|
+
steps: [
|
|
894
|
+
s(EVENTS.CHAT_STARTED, "Submitted the hanzo.ai composer", {
|
|
895
|
+
property: "source",
|
|
896
|
+
equals: "composer"
|
|
897
|
+
}),
|
|
898
|
+
s(EVENTS.CHAT_STARTED, "Landed in hanzo.chat", {
|
|
899
|
+
property: "referrerProduct",
|
|
900
|
+
equals: "site"
|
|
901
|
+
}),
|
|
902
|
+
s(EVENTS.GENERATION_COMPLETED, "Got an answer")
|
|
903
|
+
]
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
function eventsOf(id) {
|
|
907
|
+
return FUNNELS[id].steps.map((step) => step.event);
|
|
908
|
+
}
|
|
909
|
+
|
|
400
910
|
// src/goals.ts
|
|
401
911
|
var GOALS = {
|
|
402
|
-
// Signup: the conversion is signup_completed
|
|
912
|
+
// Signup: the conversion is signup_completed, along the site signup funnel.
|
|
403
913
|
signup: {
|
|
404
914
|
label: "Signup",
|
|
405
915
|
event: EVENTS.SIGNUP_COMPLETED,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
EVENTS.SIGNUP_SUBMITTED,
|
|
409
|
-
EVENTS.SIGNUP_VERIFIED,
|
|
410
|
-
EVENTS.FIRST_ACTION
|
|
411
|
-
]
|
|
916
|
+
funnelId: "signup",
|
|
917
|
+
funnel: eventsOf("signup")
|
|
412
918
|
},
|
|
413
919
|
// Sale: a completed order qualified as a plan purchase (kind=plan).
|
|
414
920
|
sale: {
|
|
415
921
|
label: "Sale",
|
|
416
922
|
event: EVENTS.ORDER_COMPLETED,
|
|
923
|
+
funnelId: "upgrade",
|
|
924
|
+
funnel: eventsOf("upgrade"),
|
|
417
925
|
filter: { property: "kind", equals: "plan" }
|
|
418
926
|
},
|
|
419
927
|
// Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
|
|
420
928
|
upgradeIntent: {
|
|
421
929
|
label: "Upgrade Intent",
|
|
422
930
|
event: EVENTS.PLAN_CLICKED,
|
|
423
|
-
|
|
931
|
+
funnelId: "upgrade",
|
|
932
|
+
funnel: eventsOf("upgrade")
|
|
933
|
+
},
|
|
934
|
+
// Activation: the ONE north-star conversion — an account that did the first
|
|
935
|
+
// valuable thing (a successful API call, a live app, a chat answer).
|
|
936
|
+
activation: {
|
|
937
|
+
label: "Activation",
|
|
938
|
+
event: EVENTS.FIRST_ACTION,
|
|
939
|
+
funnelId: "apiActivation",
|
|
940
|
+
funnel: eventsOf("apiActivation")
|
|
424
941
|
}
|
|
425
942
|
};
|
|
426
943
|
var COHORTS = {
|
|
@@ -429,6 +946,6 @@ var COHORTS = {
|
|
|
429
946
|
refCode: { field: "ref_code", label: "Referral code" }
|
|
430
947
|
};
|
|
431
948
|
|
|
432
|
-
export { Analytics, COHORTS, EVENTS, GOALS, PAGEVIEW, VERSION, createAnalytics, deriveChannel, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution };
|
|
949
|
+
export { Analytics, COHORTS, EVENTS, FUNNELS, GOALS, PAGEVIEW, PRODUCTS, VERSION, buildEnvelope, buildSentryEvent, createAnalytics, deriveChannel, eventsOf, framesFromStack, getCohort, getFirstTouch, hasAttribution, hostOf, isoWeek, parseAttribution, parseDsn, redactSecrets, scrubPII, scrubText };
|
|
433
950
|
//# sourceMappingURL=index.mjs.map
|
|
434
951
|
//# sourceMappingURL=index.mjs.map
|