@watchupltd/browser 0.1.9 → 0.2.0
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.d.mts +254 -0
- package/dist/index.d.ts +254 -10
- package/dist/index.js +669 -58
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +669 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +18 -6
- package/dist/index.d.ts.map +0 -1
- package/src/index.d.ts +0 -10
- package/src/index.d.ts.map +0 -1
- package/src/index.js +0 -60
- package/src/index.js.map +0 -1
- package/src/index.ts +0 -73
- package/tsconfig.json +0 -9
package/dist/index.js
CHANGED
|
@@ -1,60 +1,671 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/transport.ts
|
|
4
|
+
var Transport = class {
|
|
5
|
+
constructor(baseUrl, apiKey, debug = false) {
|
|
6
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
7
|
+
this.url = `${base}/api/v1/ingest/batch`;
|
|
8
|
+
this.webUrl = `${base}/api/v1/ingest/web-batch`;
|
|
9
|
+
this.headers = {
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
"X-Api-Key": apiKey
|
|
12
|
+
};
|
|
13
|
+
this.debug = debug;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Send via `fetch` with `keepalive: true`.
|
|
17
|
+
* `keepalive` lets the request outlive the current page — it's the
|
|
18
|
+
* browser equivalent of a "fire and forget" POST.
|
|
19
|
+
* Never rejects.
|
|
20
|
+
*/
|
|
21
|
+
async send(batch) {
|
|
22
|
+
try {
|
|
23
|
+
const body = JSON.stringify(batch);
|
|
24
|
+
if (body.length > 6e4) {
|
|
25
|
+
this.beacon(batch);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const res = await fetch(this.url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: this.headers,
|
|
31
|
+
body,
|
|
32
|
+
keepalive: true
|
|
33
|
+
});
|
|
34
|
+
if (this.debug && !res.ok) {
|
|
35
|
+
const text = await res.text().catch(() => "");
|
|
36
|
+
console.warn(`[watchup] ingest ${res.status}: ${text}`);
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (this.debug) console.warn("[watchup] send failed:", err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Send web analytics batch to the dedicated /web-batch endpoint.
|
|
44
|
+
* Never rejects.
|
|
45
|
+
*/
|
|
46
|
+
async sendWeb(batch) {
|
|
47
|
+
try {
|
|
48
|
+
const body = JSON.stringify(batch);
|
|
49
|
+
if (body.length > 6e4) {
|
|
50
|
+
this.beaconWeb(batch);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const res = await fetch(this.webUrl, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: this.headers,
|
|
56
|
+
body,
|
|
57
|
+
keepalive: true
|
|
58
|
+
});
|
|
59
|
+
if (this.debug && !res.ok) {
|
|
60
|
+
const text = await res.text().catch(() => "");
|
|
61
|
+
console.warn(`[watchup] web-batch ${res.status}: ${text}`);
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (this.debug) console.warn("[watchup] sendWeb failed:", err);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Send via `navigator.sendBeacon`.
|
|
69
|
+
* Returns `true` if the browser accepted the request (doesn't guarantee delivery).
|
|
70
|
+
* The server must accept `application/json` from sendBeacon via a Blob.
|
|
71
|
+
*/
|
|
72
|
+
beacon(batch) {
|
|
73
|
+
if (typeof navigator === "undefined" || !navigator.sendBeacon) return false;
|
|
74
|
+
try {
|
|
75
|
+
const blob = new Blob([JSON.stringify(batch)], { type: "application/json" });
|
|
76
|
+
return navigator.sendBeacon(this.url, blob);
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* sendBeacon variant for web analytics events.
|
|
83
|
+
*/
|
|
84
|
+
beaconWeb(batch) {
|
|
85
|
+
if (typeof navigator === "undefined" || !navigator.sendBeacon) return false;
|
|
86
|
+
try {
|
|
87
|
+
const blob = new Blob([JSON.stringify(batch)], { type: "application/json" });
|
|
88
|
+
return navigator.sendBeacon(this.webUrl, blob);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/batcher.ts
|
|
96
|
+
var Batcher = class {
|
|
97
|
+
constructor(transport, flushInterval, maxBatchSize) {
|
|
98
|
+
this.traces = [];
|
|
99
|
+
this.errors = [];
|
|
100
|
+
this.events = [];
|
|
101
|
+
this.webViews = [];
|
|
102
|
+
this.timer = null;
|
|
103
|
+
this.flushing = false;
|
|
104
|
+
this.transport = transport;
|
|
105
|
+
this.flushInterval = flushInterval;
|
|
106
|
+
this.maxBatchSize = maxBatchSize;
|
|
107
|
+
}
|
|
108
|
+
start() {
|
|
109
|
+
if (this.timer) return;
|
|
110
|
+
this.timer = setInterval(() => this.flush(), this.flushInterval);
|
|
111
|
+
document.addEventListener("visibilitychange", () => {
|
|
112
|
+
if (document.visibilityState === "hidden") this.beaconFlush();
|
|
113
|
+
});
|
|
114
|
+
window.addEventListener("pagehide", () => this.beaconFlush(), { once: true });
|
|
115
|
+
}
|
|
116
|
+
stop() {
|
|
117
|
+
if (this.timer) {
|
|
118
|
+
clearInterval(this.timer);
|
|
119
|
+
this.timer = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ── Telemetry queue ───────────────────────────────────────────────────────
|
|
123
|
+
addTrace(t) {
|
|
124
|
+
this.traces.push(t);
|
|
125
|
+
if (this.traces.length >= this.maxBatchSize) this.flush();
|
|
126
|
+
}
|
|
127
|
+
addError(e) {
|
|
128
|
+
this.errors.push(e);
|
|
129
|
+
if (this.errors.length >= Math.ceil(this.maxBatchSize / 2)) this.flush();
|
|
130
|
+
}
|
|
131
|
+
addEvent(e) {
|
|
132
|
+
this.events.push(e);
|
|
133
|
+
if (this.events.length >= this.maxBatchSize) this.flush();
|
|
134
|
+
}
|
|
135
|
+
// ── Web analytics queue ───────────────────────────────────────────────────
|
|
136
|
+
addWebView(payload) {
|
|
137
|
+
this.webViews.push(payload);
|
|
138
|
+
if (this.webViews.length >= this.maxBatchSize) this.flushWeb();
|
|
139
|
+
}
|
|
140
|
+
// ── Drain helpers ─────────────────────────────────────────────────────────
|
|
141
|
+
drainTelemetry() {
|
|
142
|
+
const traces = this.traces.splice(0);
|
|
143
|
+
const errors = this.errors.splice(0);
|
|
144
|
+
const events = this.events.splice(0);
|
|
145
|
+
if (!traces.length && !errors.length && !events.length) return null;
|
|
146
|
+
return { traces, errors, events };
|
|
147
|
+
}
|
|
148
|
+
drainWeb() {
|
|
149
|
+
const web = this.webViews.splice(0);
|
|
150
|
+
if (!web.length) return null;
|
|
151
|
+
return { web };
|
|
152
|
+
}
|
|
153
|
+
// ── Flush ─────────────────────────────────────────────────────────────────
|
|
154
|
+
flush() {
|
|
155
|
+
if (!this.flushing) {
|
|
156
|
+
const batch = this.drainTelemetry();
|
|
157
|
+
if (batch) {
|
|
158
|
+
this.flushing = true;
|
|
159
|
+
this.transport.send(batch).finally(() => {
|
|
160
|
+
this.flushing = false;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.flushWeb();
|
|
165
|
+
}
|
|
166
|
+
flushWeb() {
|
|
167
|
+
const batch = this.drainWeb();
|
|
168
|
+
if (batch) this.transport.sendWeb(batch);
|
|
169
|
+
}
|
|
170
|
+
beaconFlush() {
|
|
171
|
+
const batch = this.drainTelemetry();
|
|
172
|
+
if (batch) {
|
|
173
|
+
if (!this.transport.beacon(batch)) this.transport.send(batch);
|
|
174
|
+
}
|
|
175
|
+
const webBatch = this.drainWeb();
|
|
176
|
+
if (webBatch) {
|
|
177
|
+
if (!this.transport.beaconWeb(webBatch)) this.transport.sendWeb(webBatch);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// src/error-capture.ts
|
|
183
|
+
function captureGlobalErrors(onError, env) {
|
|
184
|
+
const handleError = (event) => {
|
|
185
|
+
var _a, _b, _c, _d;
|
|
186
|
+
onError({
|
|
187
|
+
message: event.message || "Unknown error",
|
|
188
|
+
level: "error",
|
|
189
|
+
route: window.location.pathname,
|
|
190
|
+
stack: (_a = event.error) == null ? void 0 : _a.stack,
|
|
191
|
+
context: {
|
|
192
|
+
url: window.location.href,
|
|
193
|
+
source: (_b = event.filename) != null ? _b : void 0,
|
|
194
|
+
line: (_c = event.lineno) != null ? _c : void 0,
|
|
195
|
+
col: (_d = event.colno) != null ? _d : void 0
|
|
196
|
+
},
|
|
197
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
198
|
+
...env && { environment: env }
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
const handleRejection = (event) => {
|
|
202
|
+
const reason = event.reason;
|
|
203
|
+
const isErr = reason instanceof Error;
|
|
204
|
+
onError({
|
|
205
|
+
message: isErr ? reason.message : String(reason != null ? reason : "Unhandled Promise rejection"),
|
|
206
|
+
level: "error",
|
|
207
|
+
route: window.location.pathname,
|
|
208
|
+
stack: isErr ? reason.stack : void 0,
|
|
209
|
+
context: {
|
|
210
|
+
url: window.location.href,
|
|
211
|
+
type: "unhandledrejection"
|
|
212
|
+
},
|
|
213
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
214
|
+
...env && { environment: env }
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
window.addEventListener("error", handleError);
|
|
218
|
+
window.addEventListener("unhandledrejection", handleRejection);
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener("error", handleError);
|
|
221
|
+
window.removeEventListener("unhandledrejection", handleRejection);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/perf.ts
|
|
226
|
+
function rating(ms, good, needsImprovement) {
|
|
227
|
+
if (ms <= good) return "ok";
|
|
228
|
+
if (ms <= needsImprovement) return "warn";
|
|
229
|
+
return "err";
|
|
230
|
+
}
|
|
231
|
+
function statusCode(status) {
|
|
232
|
+
return status === "err" ? 500 : status === "warn" ? 400 : 200;
|
|
233
|
+
}
|
|
234
|
+
function captureFCP(onTrace, env) {
|
|
235
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
236
|
+
try {
|
|
237
|
+
const po = new PerformanceObserver((list) => {
|
|
238
|
+
for (const entry of list.getEntries()) {
|
|
239
|
+
if (entry.name !== "first-contentful-paint") continue;
|
|
240
|
+
const ms = Math.round(entry.startTime);
|
|
241
|
+
const status = rating(ms, 1800, 3e3);
|
|
242
|
+
onTrace({
|
|
243
|
+
span: "web-vital fcp",
|
|
244
|
+
ms,
|
|
245
|
+
status_code: statusCode(status),
|
|
246
|
+
status,
|
|
247
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
248
|
+
...env && { environment: env }
|
|
249
|
+
});
|
|
250
|
+
po.disconnect();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
po.observe({ type: "paint", buffered: true });
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function captureLCP(onTrace, env) {
|
|
258
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
259
|
+
let last = null;
|
|
260
|
+
let reported = false;
|
|
261
|
+
const report = () => {
|
|
262
|
+
if (reported || !last) return;
|
|
263
|
+
reported = true;
|
|
264
|
+
try {
|
|
265
|
+
po.disconnect();
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
const ms = Math.round(last.startTime);
|
|
269
|
+
const status = rating(ms, 2500, 4e3);
|
|
270
|
+
onTrace({
|
|
271
|
+
span: "web-vital lcp",
|
|
272
|
+
ms,
|
|
273
|
+
status_code: statusCode(status),
|
|
274
|
+
status,
|
|
275
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
276
|
+
...env && { environment: env }
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
let po;
|
|
280
|
+
try {
|
|
281
|
+
po = new PerformanceObserver((list) => {
|
|
282
|
+
var _a;
|
|
283
|
+
const entries = list.getEntries();
|
|
284
|
+
if (entries.length) last = (_a = entries[entries.length - 1]) != null ? _a : null;
|
|
285
|
+
});
|
|
286
|
+
po.observe({ type: "largest-contentful-paint", buffered: true });
|
|
287
|
+
} catch {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
document.addEventListener("visibilitychange", report, { once: true });
|
|
291
|
+
document.addEventListener("keydown", report, { once: true, capture: true });
|
|
292
|
+
document.addEventListener("pointerdown", report, { once: true, capture: true });
|
|
293
|
+
}
|
|
294
|
+
function capturePageLoad(onTrace, env) {
|
|
295
|
+
const report = () => {
|
|
296
|
+
const nav = performance.getEntriesByType("navigation")[0];
|
|
297
|
+
if (!nav || nav.loadEventEnd <= 0) return;
|
|
298
|
+
const ms = Math.round(nav.loadEventEnd - nav.startTime);
|
|
299
|
+
const ttfb = Math.round(nav.responseStart - nav.requestStart);
|
|
300
|
+
const status = rating(ms, 2e3, 4e3);
|
|
301
|
+
onTrace({
|
|
302
|
+
span: "pageload",
|
|
303
|
+
ms,
|
|
304
|
+
status_code: statusCode(status),
|
|
305
|
+
status,
|
|
306
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
307
|
+
meta: { ttfb },
|
|
308
|
+
...env && { environment: env }
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
if (document.readyState === "complete") {
|
|
312
|
+
setTimeout(report, 0);
|
|
313
|
+
} else {
|
|
314
|
+
window.addEventListener("load", () => setTimeout(report, 100), { once: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/watchup.ts
|
|
319
|
+
function flagBucket(flagKey, userId) {
|
|
320
|
+
const str = `${flagKey}:${userId}`;
|
|
321
|
+
let hash = 5381;
|
|
322
|
+
for (let i = 0; i < str.length; i++) {
|
|
323
|
+
hash = hash * 33 ^ str.charCodeAt(i) | 0;
|
|
324
|
+
}
|
|
325
|
+
return (hash >>> 0) % 100;
|
|
59
326
|
}
|
|
327
|
+
function matchesTargeting(flag, ctx) {
|
|
328
|
+
var _a;
|
|
329
|
+
if (!((_a = flag.targeting_rules) == null ? void 0 : _a.length)) return true;
|
|
330
|
+
return flag.targeting_rules.every((rule) => {
|
|
331
|
+
var _a2;
|
|
332
|
+
const val = String((_a2 = ctx[rule.attribute]) != null ? _a2 : "");
|
|
333
|
+
switch (rule.operator) {
|
|
334
|
+
case "in":
|
|
335
|
+
return rule.values.includes(val);
|
|
336
|
+
case "not_in":
|
|
337
|
+
return !rule.values.includes(val);
|
|
338
|
+
case "contains":
|
|
339
|
+
return rule.values.some((v) => val.includes(v));
|
|
340
|
+
case "equals":
|
|
341
|
+
return rule.values[0] === val;
|
|
342
|
+
default:
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
var DEFAULTS = {
|
|
348
|
+
baseUrl: "https://api.watchup.site",
|
|
349
|
+
flushInterval: 5e3,
|
|
350
|
+
maxBatchSize: 100,
|
|
351
|
+
debug: false,
|
|
352
|
+
environment: "production",
|
|
353
|
+
release: "",
|
|
354
|
+
sampleRate: 1,
|
|
355
|
+
autoCapture: {
|
|
356
|
+
errors: true,
|
|
357
|
+
performance: true,
|
|
358
|
+
pageViews: true
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
var VISITOR_KEY = "__wup_vid";
|
|
362
|
+
var SESSION_KEY = "__wup_sid";
|
|
363
|
+
var Watchup = class {
|
|
364
|
+
constructor(options) {
|
|
365
|
+
this.cleanup = [];
|
|
366
|
+
this._user = null;
|
|
367
|
+
/**
|
|
368
|
+
* A random UUID generated on init. Stable for the lifetime of the page —
|
|
369
|
+
* useful for correlating all events from one user session.
|
|
370
|
+
*/
|
|
371
|
+
this.sessionId = crypto.randomUUID();
|
|
372
|
+
// Feature flags
|
|
373
|
+
this._flags = /* @__PURE__ */ new Map();
|
|
374
|
+
this._flagTimer = null;
|
|
375
|
+
if (!options.apiKey) {
|
|
376
|
+
throw new Error("[watchup] apiKey is required.");
|
|
377
|
+
}
|
|
378
|
+
this.cfg = {
|
|
379
|
+
...DEFAULTS,
|
|
380
|
+
autoCapture: { ...DEFAULTS.autoCapture, ...options.autoCapture },
|
|
381
|
+
...options
|
|
382
|
+
};
|
|
383
|
+
const transport = new Transport(this.cfg.baseUrl, this.cfg.apiKey, this.cfg.debug);
|
|
384
|
+
this.batcher = new Batcher(transport, this.cfg.flushInterval, this.cfg.maxBatchSize);
|
|
385
|
+
this.batcher.start();
|
|
386
|
+
this.visitorId = this._getOrCreateVisitorId();
|
|
387
|
+
this.webSessionId = this._getOrCreateSessionId();
|
|
388
|
+
this._setupAutoCapture();
|
|
389
|
+
this._fetchFlags();
|
|
390
|
+
this._flagTimer = setInterval(() => this._fetchFlags(), 3e4);
|
|
391
|
+
this.cleanup.push(() => {
|
|
392
|
+
if (this._flagTimer) clearInterval(this._flagTimer);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
async _fetchFlags() {
|
|
396
|
+
var _a;
|
|
397
|
+
try {
|
|
398
|
+
const res = await fetch(`${this.cfg.baseUrl}/api/v1/flags`, {
|
|
399
|
+
headers: { "X-Api-Key": this.cfg.apiKey }
|
|
400
|
+
});
|
|
401
|
+
if (!res.ok) return;
|
|
402
|
+
const json = await res.json();
|
|
403
|
+
if (json.ok && ((_a = json.data) == null ? void 0 : _a.flags)) {
|
|
404
|
+
this._flags.clear();
|
|
405
|
+
for (const flag of json.data.flags) {
|
|
406
|
+
this._flags.set(flag.key, flag);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// ── Visitor / session identity helpers ─────────────────────────────────────
|
|
413
|
+
_getOrCreateVisitorId() {
|
|
414
|
+
try {
|
|
415
|
+
let id = localStorage.getItem(VISITOR_KEY);
|
|
416
|
+
if (!id) {
|
|
417
|
+
id = crypto.randomUUID();
|
|
418
|
+
localStorage.setItem(VISITOR_KEY, id);
|
|
419
|
+
}
|
|
420
|
+
return id;
|
|
421
|
+
} catch {
|
|
422
|
+
return crypto.randomUUID();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
_getOrCreateSessionId() {
|
|
426
|
+
try {
|
|
427
|
+
let id = sessionStorage.getItem(SESSION_KEY);
|
|
428
|
+
if (!id) {
|
|
429
|
+
id = crypto.randomUUID();
|
|
430
|
+
sessionStorage.setItem(SESSION_KEY, id);
|
|
431
|
+
}
|
|
432
|
+
return id;
|
|
433
|
+
} catch {
|
|
434
|
+
return this.sessionId;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// ── User identification ───────────────────────────────────────────────────
|
|
438
|
+
/**
|
|
439
|
+
* Attach a user to all subsequent errors, traces, and events.
|
|
440
|
+
* Call this after login; the context persists until `clearUser()` or page reload.
|
|
441
|
+
*
|
|
442
|
+
* @example
|
|
443
|
+
* watchup.setUser({ id: '42', email: 'ada@example.com', name: 'Ada Lovelace' });
|
|
444
|
+
*/
|
|
445
|
+
setUser(user) {
|
|
446
|
+
this._user = { ...user };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Remove the current user context (e.g. after logout).
|
|
450
|
+
*/
|
|
451
|
+
clearUser() {
|
|
452
|
+
this._user = null;
|
|
453
|
+
}
|
|
454
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
455
|
+
/**
|
|
456
|
+
* Track a custom analytics event.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* watchup.track('button.clicked', { label: 'Sign Up', variant: 'A' });
|
|
460
|
+
*/
|
|
461
|
+
track(name, properties) {
|
|
462
|
+
if (!name) return;
|
|
463
|
+
const event = {
|
|
464
|
+
name,
|
|
465
|
+
...properties && Object.keys(properties).length && { properties },
|
|
466
|
+
occurred_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
467
|
+
};
|
|
468
|
+
this.batcher.addEvent(event);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Track a web analytics page view (or custom web event).
|
|
472
|
+
* Enriches the payload with visitor context, UTM params, and device info.
|
|
473
|
+
*
|
|
474
|
+
* Normally called automatically. Call manually when you need custom event_name.
|
|
475
|
+
*
|
|
476
|
+
* @example
|
|
477
|
+
* watchup.trackWebView({ event_name: 'conversion', path: '/checkout/success' });
|
|
478
|
+
*/
|
|
479
|
+
trackWebView(overrides = {}) {
|
|
480
|
+
var _a, _b;
|
|
481
|
+
const url = new URL(window.location.href);
|
|
482
|
+
const params = url.searchParams;
|
|
483
|
+
const payload = {
|
|
484
|
+
path: url.pathname + (url.search || ""),
|
|
485
|
+
hostname: url.hostname,
|
|
486
|
+
referrer: document.referrer || void 0,
|
|
487
|
+
title: document.title || void 0,
|
|
488
|
+
screen_w: (_a = window.screen) == null ? void 0 : _a.width,
|
|
489
|
+
screen_h: (_b = window.screen) == null ? void 0 : _b.height,
|
|
490
|
+
lang: navigator.language || void 0,
|
|
491
|
+
timezone: this._timezone(),
|
|
492
|
+
// UTM parameters
|
|
493
|
+
utm_source: params.get("utm_source") || void 0,
|
|
494
|
+
utm_medium: params.get("utm_medium") || void 0,
|
|
495
|
+
utm_campaign: params.get("utm_campaign") || void 0,
|
|
496
|
+
utm_term: params.get("utm_term") || void 0,
|
|
497
|
+
utm_content: params.get("utm_content") || void 0,
|
|
498
|
+
// Identity (raw; the server hashes before storing)
|
|
499
|
+
visitor_id: this.visitorId,
|
|
500
|
+
session_id: this.webSessionId,
|
|
501
|
+
event_name: "pageview",
|
|
502
|
+
occurred_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
503
|
+
// Apply caller overrides last
|
|
504
|
+
...overrides
|
|
505
|
+
};
|
|
506
|
+
this.batcher.addWebView(payload);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Manually capture an error.
|
|
510
|
+
*
|
|
511
|
+
* @example
|
|
512
|
+
* try { ... } catch (err) {
|
|
513
|
+
* watchup.captureError(err, { component: 'CheckoutForm' });
|
|
514
|
+
* }
|
|
515
|
+
*/
|
|
516
|
+
captureError(error, context) {
|
|
517
|
+
const { route, level = "error", ...rest } = context != null ? context : {};
|
|
518
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
519
|
+
const payload = {
|
|
520
|
+
message: err.message,
|
|
521
|
+
level,
|
|
522
|
+
...err.stack !== void 0 && { stack: err.stack },
|
|
523
|
+
route: route != null ? route : window.location.pathname,
|
|
524
|
+
...Object.keys(rest).length && {
|
|
525
|
+
context: { ...rest, url: window.location.href }
|
|
526
|
+
},
|
|
527
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
528
|
+
environment: this.cfg.environment,
|
|
529
|
+
...this.cfg.release && { release: this.cfg.release },
|
|
530
|
+
...this._user && { user: this._user }
|
|
531
|
+
};
|
|
532
|
+
this.batcher.addError(payload);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Time any async operation and record it as a trace.
|
|
536
|
+
* Returns an `end()` function — call it when the operation finishes.
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* const end = watchup.startTrace('fetch /api/cart');
|
|
540
|
+
* const cart = await fetch('/api/cart');
|
|
541
|
+
* end({ status: cart.ok ? 'ok' : 'err' });
|
|
542
|
+
*/
|
|
543
|
+
startTrace(span) {
|
|
544
|
+
const start = Date.now();
|
|
545
|
+
return (opts = {}) => {
|
|
546
|
+
var _a;
|
|
547
|
+
const status = (_a = opts.status) != null ? _a : "ok";
|
|
548
|
+
this.batcher.addTrace({
|
|
549
|
+
span,
|
|
550
|
+
ms: Date.now() - start,
|
|
551
|
+
status_code: status === "err" ? 500 : status === "warn" ? 400 : 200,
|
|
552
|
+
status,
|
|
553
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
554
|
+
environment: this.cfg.environment,
|
|
555
|
+
...this.cfg.release && { release: this.cfg.release },
|
|
556
|
+
...opts.meta && { meta: opts.meta },
|
|
557
|
+
...this._user && { user: this._user }
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
// ── Feature flags ─────────────────────────────────────────────────────────
|
|
562
|
+
/**
|
|
563
|
+
* Check whether a feature flag is enabled. Evaluated locally — zero
|
|
564
|
+
* network latency. Flag rules refresh every 30 seconds in the background.
|
|
565
|
+
* Falls back to the identified user (via `setUser`) when no context is given.
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* if (watchup.isEnabled('new-checkout')) {
|
|
569
|
+
* renderNewCheckout();
|
|
570
|
+
* }
|
|
571
|
+
*/
|
|
572
|
+
isEnabled(key, ctx = {}) {
|
|
573
|
+
var _a, _b, _c, _d;
|
|
574
|
+
const flag = this._flags.get(key);
|
|
575
|
+
if (!flag || !flag.enabled) return false;
|
|
576
|
+
const mergedCtx = { userId: (_a = this._user) == null ? void 0 : _a.id, email: (_b = this._user) == null ? void 0 : _b.email, ...ctx };
|
|
577
|
+
if (!matchesTargeting(flag, mergedCtx)) return false;
|
|
578
|
+
if (flag.rollout_percentage >= 100) return true;
|
|
579
|
+
if (flag.rollout_percentage <= 0) return false;
|
|
580
|
+
const userId = String((_d = (_c = mergedCtx.userId) != null ? _c : mergedCtx.email) != null ? _d : this.visitorId);
|
|
581
|
+
return flagBucket(key, userId) < flag.rollout_percentage;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Get the variant key for a multivariate (A/B) flag.
|
|
585
|
+
* Returns `"control"` if the flag is off or the visitor isn't in the rollout.
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* const variant = watchup.getVariant('pricing-layout');
|
|
589
|
+
* // → "control" | "variant-a" | "variant-b"
|
|
590
|
+
*/
|
|
591
|
+
getVariant(key, ctx = {}) {
|
|
592
|
+
var _a, _b, _c, _d, _e;
|
|
593
|
+
if (!this.isEnabled(key, ctx)) return "control";
|
|
594
|
+
const flag = this._flags.get(key);
|
|
595
|
+
if (!((_a = flag.variants) == null ? void 0 : _a.length)) return "on";
|
|
596
|
+
const mergedCtx = { userId: (_b = this._user) == null ? void 0 : _b.id, email: (_c = this._user) == null ? void 0 : _c.email, ...ctx };
|
|
597
|
+
const userId = String((_e = (_d = mergedCtx.userId) != null ? _d : mergedCtx.email) != null ? _e : this.visitorId);
|
|
598
|
+
const bucket = flagBucket(key, userId);
|
|
599
|
+
let cumulative = 0;
|
|
600
|
+
for (const variant of flag.variants) {
|
|
601
|
+
cumulative += variant.weight;
|
|
602
|
+
if (bucket < cumulative) return variant.key;
|
|
603
|
+
}
|
|
604
|
+
return flag.variants[flag.variants.length - 1].key;
|
|
605
|
+
}
|
|
606
|
+
/** Immediately flush all queued items (both telemetry and web analytics). */
|
|
607
|
+
flush() {
|
|
608
|
+
this.batcher.flush();
|
|
609
|
+
}
|
|
610
|
+
/** Stop the flush timer and release all listeners. */
|
|
611
|
+
shutdown() {
|
|
612
|
+
this.batcher.stop();
|
|
613
|
+
this.batcher.flush();
|
|
614
|
+
this.cleanup.forEach((fn) => fn());
|
|
615
|
+
}
|
|
616
|
+
// ── Auto-capture setup ────────────────────────────────────────────────────
|
|
617
|
+
_setupAutoCapture() {
|
|
618
|
+
const { autoCapture, environment } = this.cfg;
|
|
619
|
+
if (autoCapture.errors) {
|
|
620
|
+
this.cleanup.push(
|
|
621
|
+
captureGlobalErrors((e) => this.batcher.addError(e), environment)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (autoCapture.performance) {
|
|
625
|
+
captureFCP((t) => this.batcher.addTrace(t), environment);
|
|
626
|
+
captureLCP((t) => this.batcher.addTrace(t), environment);
|
|
627
|
+
capturePageLoad((t) => this.batcher.addTrace(t), environment);
|
|
628
|
+
}
|
|
629
|
+
if (autoCapture.pageViews) {
|
|
630
|
+
this._setupPageViewTracking();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
_setupPageViewTracking() {
|
|
634
|
+
const trackView = () => {
|
|
635
|
+
setTimeout(() => this.trackWebView(), 0);
|
|
636
|
+
};
|
|
637
|
+
if (document.readyState === "loading") {
|
|
638
|
+
document.addEventListener("DOMContentLoaded", trackView, { once: true });
|
|
639
|
+
} else {
|
|
640
|
+
setTimeout(() => this.trackWebView(), 0);
|
|
641
|
+
}
|
|
642
|
+
const origPush = history.pushState.bind(history);
|
|
643
|
+
const origReplace = history.replaceState.bind(history);
|
|
644
|
+
history.pushState = (...args) => {
|
|
645
|
+
origPush(...args);
|
|
646
|
+
trackView();
|
|
647
|
+
};
|
|
648
|
+
history.replaceState = (...args) => {
|
|
649
|
+
origReplace(...args);
|
|
650
|
+
};
|
|
651
|
+
const onPopState = () => trackView();
|
|
652
|
+
window.addEventListener("popstate", onPopState);
|
|
653
|
+
this.cleanup.push(() => {
|
|
654
|
+
history.pushState = origPush;
|
|
655
|
+
history.replaceState = origReplace;
|
|
656
|
+
window.removeEventListener("popstate", onPopState);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
660
|
+
_timezone() {
|
|
661
|
+
try {
|
|
662
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
663
|
+
} catch {
|
|
664
|
+
return void 0;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
exports.Watchup = Watchup;
|
|
670
|
+
//# sourceMappingURL=index.js.map
|
|
60
671
|
//# sourceMappingURL=index.js.map
|