@chaja/sdk-browser 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,23 @@
1
- # Temporary Holding Version
1
+ # @chaja/sdk-browser
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Error tracking for browsers, reporting to a self-hosted [Chajá](https://github.com/emiliodominguez/chaja) server.
4
+
5
+ ```sh
6
+ pnpm add @chaja/sdk-browser
7
+ ```
8
+
9
+ ```ts
10
+ import { captureException, init } from "@chaja/sdk-browser";
11
+
12
+ init({ endpoint: "https://chaja.example.com", key: "<project ingest key>", release: "1.4.0", environment: "production" });
13
+
14
+ try {
15
+ await checkout();
16
+ } catch (error) {
17
+ captureException(error, { tags: { step: "payment" } });
18
+ }
19
+ ```
20
+
21
+ Uncaught errors and unhandled rejections are captured automatically, with console, `fetch`, navigation and click breadcrumbs.
22
+ Upload sourcemaps with [`@chaja/cli`](https://www.npmjs.com/package/@chaja/cli) so minified frames show your source. All options:
23
+ [docs/sdk.md](https://github.com/emiliodominguez/chaja/blob/main/docs/sdk.md).
@@ -0,0 +1,130 @@
1
+ import { BreadcrumbInput, CaptureContext, CaptureContext as CaptureContext$1, CaptureOptions, CaptureOptions as CaptureOptions$1, Client, Client as Client$1, ClientOptions, ClientOptions as ClientOptions$1 } from "@chaja/sdk-core";
2
+ //#region src/index.d.ts
3
+ export interface BrowserOptions extends Omit<ClientOptions$1, "service"> {
4
+ /**
5
+ * Service name; defaults to the page host.
6
+ */
7
+ service?: string;
8
+ /**
9
+ * Which breadcrumbs to record automatically. All on by default.
10
+ */
11
+ breadcrumbs?: {
12
+ console?: boolean;
13
+ fetch?: boolean;
14
+ navigation?: boolean;
15
+ clicks?: boolean;
16
+ };
17
+ /**
18
+ * Capture uncaught errors and unhandled promise rejections. Default true.
19
+ */
20
+ globalHandlers?: boolean;
21
+ /**
22
+ * Send a `$pageview` on load and on every client-side navigation to a new path. Default true.
23
+ */
24
+ capturePageviews?: boolean;
25
+ /**
26
+ * Load feature flags on start and after `identify` or `reset`. Default false.
27
+ */
28
+ flags?: boolean;
29
+ /**
30
+ * The window to instrument (tests pass a fake one).
31
+ */
32
+ window?: Window & typeof globalThis;
33
+ }
34
+ export type FlagValue = boolean | string;
35
+ export interface BrowserClient extends Client$1 {
36
+ /**
37
+ * Remove every listener and wrapper this SDK installed.
38
+ */
39
+ close(): void;
40
+ /**
41
+ * Whether a flag is on for the current person (a variant counts as on); undefined until flags have loaded once.
42
+ */
43
+ isFeatureEnabled(key: string): boolean | undefined;
44
+ /**
45
+ * A flag's value: `false`, `true`, or the variant key; undefined until flags have loaded once.
46
+ */
47
+ getFeatureFlag(key: string): FlagValue | undefined;
48
+ /**
49
+ * Called with every flag whenever they load; right away when they already have. Returns a function that stops it.
50
+ */
51
+ onFeatureFlags(listener: (flags: Record<string, FlagValue>) => void): () => void;
52
+ /**
53
+ * Fetch the current person's flags again.
54
+ */
55
+ reloadFeatureFlags(): Promise<void>;
56
+ }
57
+ /**
58
+ * Start the browser SDK: capture uncaught errors and rejections, record breadcrumbs and send events to Chajá.
59
+ *
60
+ * @param options - SDK options.
61
+ * @returns The client; calling `init` again replaces it.
62
+ */
63
+ export declare function init(options: BrowserOptions): BrowserClient;
64
+ /**
65
+ * Capture an error with the client from `init`.
66
+ *
67
+ * @param error - Thrown value.
68
+ * @param context - Extra context.
69
+ * @returns Event id, if sent.
70
+ */
71
+ export declare function captureException(error: unknown, context?: CaptureContext$1): string | undefined;
72
+ /**
73
+ * Capture a message with the client from `init`.
74
+ *
75
+ * @param message - Message.
76
+ * @param level - Level.
77
+ * @param context - Extra context.
78
+ * @returns Event id, if sent.
79
+ */
80
+ export declare function captureMessage(message: string, level?: Parameters<Client$1["captureMessage"]>[1], context?: CaptureContext$1): string | undefined;
81
+ /**
82
+ * The client created by `init`, if any.
83
+ *
84
+ * @returns Current client.
85
+ */
86
+ export declare function getClient(): BrowserClient | undefined;
87
+ /**
88
+ * Record a product analytics event with the client from `init`.
89
+ *
90
+ * @param name - Event name, such as `signed_up`.
91
+ * @param properties - Event properties.
92
+ * @param options - Person override and person properties.
93
+ * @returns Event id, if sent.
94
+ */
95
+ export declare function capture(name: string, properties?: Parameters<Client$1["capture"]>[1], options?: CaptureOptions$1): string | undefined;
96
+ /**
97
+ * Name the current person with your app's user id (after sign-in).
98
+ *
99
+ * @param distinctId - Your user id.
100
+ * @param set - Person properties, such as `{ email, plan }`.
101
+ * @param setOnce - Person properties to keep from the first time they are set.
102
+ */
103
+ export declare function identify(distinctId: string, set?: Parameters<Client$1["identify"]>[1], setOnce?: Parameters<Client$1["identify"]>[2]): void;
104
+ /**
105
+ * Forget the current person (on sign-out).
106
+ */
107
+ export declare function reset(): void;
108
+ /**
109
+ * Whether a flag is on for the current person, with the client from `init` (needs `flags: true`).
110
+ *
111
+ * @param key - Flag key.
112
+ * @returns On, off, or undefined before flags load.
113
+ */
114
+ export declare function isFeatureEnabled(key: string): boolean | undefined;
115
+ /**
116
+ * A flag's value for the current person, with the client from `init` (needs `flags: true`).
117
+ *
118
+ * @param key - Flag key.
119
+ * @returns `false`, `true`, the variant key, or undefined before flags load.
120
+ */
121
+ export declare function getFeatureFlag(key: string): FlagValue | undefined;
122
+ /**
123
+ * Run a callback whenever flags load, with the client from `init`.
124
+ *
125
+ * @param listener - Called with every flag.
126
+ * @returns A function that stops it.
127
+ */
128
+ export declare function onFeatureFlags(listener: (flags: Record<string, FlagValue>) => void): () => void;
129
+ //#endregion
130
+ export type { BreadcrumbInput, CaptureContext, CaptureOptions, Client, ClientOptions };
package/dist/index.mjs ADDED
@@ -0,0 +1,537 @@
1
+ import { SDK_VERSION, createClient, parseRetryAfter, randomEventId } from "@chaja/sdk-core";
2
+ //#region src/index.ts
3
+ let current;
4
+ /**
5
+ * A session ends after this long without events, or after a day.
6
+ */
7
+ const SESSION_IDLE_MS = 18e5;
8
+ const SESSION_MAX_MS = 864e5;
9
+ /**
10
+ * JSON state in localStorage that survives private modes and blocked storage by falling back to memory.
11
+ *
12
+ * @param view - Window.
13
+ * @param key - Storage key.
14
+ * @returns Read and write functions.
15
+ */
16
+ function storedValue(view, key) {
17
+ let memory;
18
+ return {
19
+ read() {
20
+ try {
21
+ const raw = view.localStorage.getItem(key);
22
+ return raw === null ? memory : JSON.parse(raw);
23
+ } catch {
24
+ return memory;
25
+ }
26
+ },
27
+ write(value) {
28
+ memory = value;
29
+ try {
30
+ if (value === void 0) view.localStorage.removeItem(key);
31
+ else view.localStorage.setItem(key, JSON.stringify(value));
32
+ } catch {}
33
+ }
34
+ };
35
+ }
36
+ /**
37
+ * Narrow stored or fetched flags.
38
+ *
39
+ * @param value - Parsed JSON.
40
+ * @returns Flags, or undefined.
41
+ */
42
+ function flagsOf(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return;
44
+ const flags = {};
45
+ for (const [key, flag] of Object.entries(value)) if (typeof flag === "boolean" || typeof flag === "string") flags[key] = flag;
46
+ return flags;
47
+ }
48
+ /**
49
+ * Narrow stored identity.
50
+ *
51
+ * @param value - Parsed JSON.
52
+ * @returns Identity, or undefined.
53
+ */
54
+ function identityOf(value) {
55
+ return typeof value === "object" && value !== null && "distinctId" in value && typeof value.distinctId === "string" && "identified" in value && typeof value.identified === "boolean" ? {
56
+ distinctId: value.distinctId,
57
+ identified: value.identified
58
+ } : void 0;
59
+ }
60
+ /**
61
+ * Narrow a stored session.
62
+ *
63
+ * @param value - Parsed JSON.
64
+ * @returns Session, or undefined.
65
+ */
66
+ function sessionOf(value) {
67
+ return typeof value === "object" && value !== null && "id" in value && typeof value.id === "string" && "startedAt" in value && typeof value.startedAt === "number" && "lastActivityAt" in value && typeof value.lastActivityAt === "number" ? {
68
+ id: value.id,
69
+ startedAt: value.startedAt,
70
+ lastActivityAt: value.lastActivityAt
71
+ } : void 0;
72
+ }
73
+ /**
74
+ * Describe a clicked element compactly: `button#save.primary`.
75
+ *
76
+ * @param target - Event target.
77
+ * @returns Selector-like text.
78
+ */
79
+ function describeElement(target) {
80
+ if (!isElement(target)) return;
81
+ const element = target;
82
+ const id = element.id ? `#${element.id}` : "";
83
+ const classes = typeof element.className === "string" && element.className ? `.${element.className.trim().split(/\s+/u).slice(0, 3).join(".")}` : "";
84
+ const label = element.getAttribute("aria-label") ?? element.getAttribute("name");
85
+ return `${element.tagName.toLowerCase()}${id}${classes}${label ? `[${label.slice(0, 40)}]` : ""}`;
86
+ }
87
+ /**
88
+ * Whether an event target is a DOM element.
89
+ *
90
+ * @param target - Event target.
91
+ * @returns True for elements.
92
+ */
93
+ function isElement(target) {
94
+ return typeof target === "object" && target !== null && "tagName" in target && typeof target.tagName === "string" && "getAttribute" in target;
95
+ }
96
+ /**
97
+ * Start the browser SDK: capture uncaught errors and rejections, record breadcrumbs and send events to Chajá.
98
+ *
99
+ * @param options - SDK options.
100
+ * @returns The client; calling `init` again replaces it.
101
+ */
102
+ function init(options) {
103
+ current?.close();
104
+ const view = options.window ?? window;
105
+ const envelopeUrl = `${options.endpoint.replace(/\/+$/u, "")}/api/v1/envelope`;
106
+ const cleanups = [];
107
+ const nativeFetch = Reflect.get(view, "fetch");
108
+ const originalFetch = view.fetch.bind(view);
109
+ const transport = {
110
+ async send(body) {
111
+ try {
112
+ const response = await originalFetch(envelopeUrl, {
113
+ method: "POST",
114
+ headers: {
115
+ "content-type": "application/json",
116
+ "x-chaja-key": options.key
117
+ },
118
+ body,
119
+ keepalive: body.length < 6e4
120
+ });
121
+ return {
122
+ status: response.status,
123
+ ...response.status === 429 || response.status === 503 ? optionalRetry(response.headers.get("retry-after")) : {}
124
+ };
125
+ } catch {
126
+ return { status: 0 };
127
+ }
128
+ },
129
+ sendFinal(body) {
130
+ const navigatorRef = view.navigator;
131
+ return typeof navigatorRef.sendBeacon === "function" && navigatorRef.sendBeacon(`${envelopeUrl}?key=${encodeURIComponent(options.key)}`, new Blob([body], { type: "text/plain" }));
132
+ }
133
+ };
134
+ const identityStore = storedValue(view, `chaja_id_${options.key}`);
135
+ const sessionStore = storedValue(view, `chaja_session_${options.key}`);
136
+ const flagStore = storedValue(view, `chaja_flags_${options.key}`);
137
+ const flagsUrl = `${options.endpoint.replace(/\/+$/u, "")}/api/v1/flags`;
138
+ const flagListeners = /* @__PURE__ */ new Set();
139
+ const reported = /* @__PURE__ */ new Set();
140
+ let flags = options.flags ? flagsOf(flagStore.read()) : void 0;
141
+ const client = createClient({
142
+ ...options,
143
+ service: options.service ?? view.location.host
144
+ }, {
145
+ sdk: {
146
+ name: "chaja.javascript.browser",
147
+ version: SDK_VERSION
148
+ },
149
+ platform: "javascript",
150
+ transport,
151
+ eventId: randomEventId,
152
+ now: Date.now,
153
+ random: Math.random,
154
+ schedule(callback, delayMs) {
155
+ return view.setTimeout(callback, delayMs);
156
+ },
157
+ cancel(handle) {
158
+ if (typeof handle === "number") view.clearTimeout(handle);
159
+ },
160
+ enrich(item) {
161
+ return {
162
+ ...item,
163
+ request: {
164
+ url: view.location.href,
165
+ headers: { "user-agent": view.navigator.userAgent },
166
+ ...item.request
167
+ }
168
+ };
169
+ },
170
+ identity: {
171
+ load() {
172
+ return identityOf(identityStore.read());
173
+ },
174
+ save(state) {
175
+ identityStore.write(state);
176
+ }
177
+ },
178
+ session: {
179
+ current() {
180
+ const now = Date.now();
181
+ const stored = sessionOf(sessionStore.read());
182
+ const session = stored && now - stored.lastActivityAt < SESSION_IDLE_MS && now - stored.startedAt < SESSION_MAX_MS ? {
183
+ ...stored,
184
+ lastActivityAt: now
185
+ } : {
186
+ id: randomEventId(),
187
+ startedAt: now,
188
+ lastActivityAt: now
189
+ };
190
+ sessionStore.write(session);
191
+ return session.id;
192
+ },
193
+ reset() {
194
+ sessionStore.write(void 0);
195
+ }
196
+ },
197
+ eventProperties() {
198
+ return {
199
+ $current_url: view.location.href,
200
+ $host: view.location.host,
201
+ $pathname: view.location.pathname,
202
+ $referrer: view.document.referrer,
203
+ $screen_width: view.screen.width,
204
+ $screen_height: view.screen.height,
205
+ $viewport_width: view.innerWidth,
206
+ $viewport_height: view.innerHeight,
207
+ $lib: "chaja.javascript.browser",
208
+ $lib_version: SDK_VERSION,
209
+ ...flagProperties()
210
+ };
211
+ }
212
+ });
213
+ let lastPage;
214
+ /**
215
+ * The loaded flags as `$feature/<key>` event properties, so Insights can compare people by flag.
216
+ *
217
+ * @returns Properties.
218
+ */
219
+ function flagProperties() {
220
+ const properties = {};
221
+ for (const [key, value] of Object.entries(flags ?? {})) properties[`$feature/${key}`] = value;
222
+ return properties;
223
+ }
224
+ /**
225
+ * Fetch the current person's flags, keep them for the next page load and tell listeners.
226
+ */
227
+ async function reloadFeatureFlags() {
228
+ try {
229
+ const response = await originalFetch(flagsUrl, {
230
+ method: "POST",
231
+ headers: {
232
+ "content-type": "application/json",
233
+ "x-chaja-key": options.key
234
+ },
235
+ body: JSON.stringify({ distinctId: client.getDistinctId() })
236
+ });
237
+ const body = response.ok ? await response.json() : void 0;
238
+ const loaded = typeof body === "object" && body !== null && "flags" in body ? flagsOf(body.flags) : void 0;
239
+ if (!loaded) return;
240
+ flags = loaded;
241
+ flagStore.write(loaded);
242
+ for (const listener of flagListeners) listener(loaded);
243
+ } catch {}
244
+ }
245
+ /**
246
+ * Read a flag, recording the first read of each value as `$feature_flag_called`.
247
+ *
248
+ * @param key - Flag key.
249
+ * @returns Value, if loaded.
250
+ */
251
+ function readFlag(key) {
252
+ const value = flags?.[key];
253
+ if (value !== void 0 && !reported.has(`${key}:${String(value)}`)) {
254
+ reported.add(`${key}:${String(value)}`);
255
+ client.capture("$feature_flag_called", {
256
+ $feature_flag: key,
257
+ $feature_flag_response: value
258
+ });
259
+ }
260
+ return value;
261
+ }
262
+ /**
263
+ * Send a page view when the path or query changed since the last one.
264
+ */
265
+ function pageview() {
266
+ const page = `${view.location.pathname}${view.location.search}`;
267
+ if (options.capturePageviews !== false && page !== lastPage) {
268
+ lastPage = page;
269
+ client.capture("$pageview");
270
+ }
271
+ }
272
+ const crumbs = options.breadcrumbs ?? {};
273
+ /**
274
+ * Listen to a window event and remember to remove the listener.
275
+ *
276
+ * @param type - Event type.
277
+ * @param listener - Listener.
278
+ * @param useCapture - Use the capture phase.
279
+ */
280
+ function listen(type, listener, useCapture = false) {
281
+ view.addEventListener(type, listener, useCapture);
282
+ cleanups.push(function remove() {
283
+ view.removeEventListener(type, listener, useCapture);
284
+ });
285
+ }
286
+ if (options.globalHandlers !== false) {
287
+ listen("error", function onError(event) {
288
+ const error = event.error ?? new Error(event.message || "Script error");
289
+ client.captureException(error, { mechanism: {
290
+ type: "onerror",
291
+ handled: false
292
+ } });
293
+ });
294
+ listen("unhandledrejection", function onRejection(event) {
295
+ client.captureException(event.reason, { mechanism: {
296
+ type: "onunhandledrejection",
297
+ handled: false
298
+ } });
299
+ });
300
+ }
301
+ listen("pagehide", function onPageHide() {
302
+ client.sendFinal();
303
+ });
304
+ if (crumbs.console !== false) for (const method of [
305
+ "warn",
306
+ "error",
307
+ "info",
308
+ "log"
309
+ ]) {
310
+ const original = view.console[method];
311
+ view.console[method] = function patched(...args) {
312
+ client.addBreadcrumb({
313
+ category: "console",
314
+ level: method === "warn" ? "warning" : method === "error" ? "error" : "info",
315
+ message: args.map(function text(arg) {
316
+ return typeof arg === "string" ? arg : arg instanceof Error ? `${arg.name}: ${arg.message}` : safeJson(arg);
317
+ }).join(" ").slice(0, 1e3)
318
+ });
319
+ original.apply(view.console, args);
320
+ };
321
+ cleanups.push(function restore() {
322
+ view.console[method] = original;
323
+ });
324
+ }
325
+ if (crumbs.fetch !== false) {
326
+ view.fetch = async function trackedFetch(input, requestInit) {
327
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
328
+ const method = (requestInit?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
329
+ const started = Date.now();
330
+ if (url.startsWith(envelopeUrl)) return originalFetch(input, requestInit);
331
+ try {
332
+ const response = await originalFetch(input, requestInit);
333
+ client.addBreadcrumb({
334
+ category: "fetch",
335
+ level: response.status >= 500 ? "error" : response.status >= 400 ? "warning" : "info",
336
+ message: `${method} ${url}`,
337
+ data: {
338
+ status: response.status,
339
+ durationMs: Date.now() - started
340
+ }
341
+ });
342
+ return response;
343
+ } catch (error) {
344
+ client.addBreadcrumb({
345
+ category: "fetch",
346
+ level: "error",
347
+ message: `${method} ${url}`,
348
+ data: {
349
+ failed: true,
350
+ durationMs: Date.now() - started
351
+ }
352
+ });
353
+ throw error;
354
+ }
355
+ };
356
+ cleanups.push(function restore() {
357
+ Reflect.set(view, "fetch", nativeFetch);
358
+ });
359
+ }
360
+ let last = view.location.href;
361
+ /**
362
+ * Record a URL change as a breadcrumb.
363
+ */
364
+ function navigated() {
365
+ const next = view.location.href;
366
+ if (crumbs.navigation !== false && next !== last) client.addBreadcrumb({
367
+ category: "navigation",
368
+ message: `${last} → ${next}`
369
+ });
370
+ last = next;
371
+ }
372
+ for (const method of ["pushState", "replaceState"]) {
373
+ const original = view.history[method];
374
+ view.history[method] = function patched(...args) {
375
+ const result = original.apply(this, args);
376
+ navigated();
377
+ pageview();
378
+ return result;
379
+ };
380
+ cleanups.push(function restore() {
381
+ view.history[method] = original;
382
+ });
383
+ }
384
+ listen("popstate", function onPopState() {
385
+ navigated();
386
+ pageview();
387
+ });
388
+ if (crumbs.clicks !== false) listen("click", function onClick(event) {
389
+ const target = describeElement(event.target);
390
+ if (target) client.addBreadcrumb({
391
+ category: "ui.click",
392
+ message: target
393
+ });
394
+ }, true);
395
+ const instance = {
396
+ ...client,
397
+ identify(distinctId, set, setOnce) {
398
+ const before = client.getDistinctId();
399
+ client.identify(distinctId, set, setOnce);
400
+ if (options.flags && client.getDistinctId() !== before) reloadFeatureFlags();
401
+ },
402
+ reset() {
403
+ client.reset();
404
+ reported.clear();
405
+ if (options.flags) reloadFeatureFlags();
406
+ },
407
+ isFeatureEnabled(key) {
408
+ const value = readFlag(key);
409
+ return value === void 0 ? void 0 : value !== false;
410
+ },
411
+ getFeatureFlag: readFlag,
412
+ onFeatureFlags(listener) {
413
+ flagListeners.add(listener);
414
+ if (flags) listener(flags);
415
+ return function stop() {
416
+ flagListeners.delete(listener);
417
+ };
418
+ },
419
+ reloadFeatureFlags,
420
+ close() {
421
+ for (const cleanup of cleanups.splice(0)) cleanup();
422
+ if (current === instance) current = void 0;
423
+ }
424
+ };
425
+ current = instance;
426
+ pageview();
427
+ if (options.flags) reloadFeatureFlags();
428
+ return instance;
429
+ }
430
+ /**
431
+ * Build the optional retry-after field.
432
+ *
433
+ * @param header - Retry-After header.
434
+ * @returns Field to spread into a transport result.
435
+ */
436
+ function optionalRetry(header) {
437
+ const seconds = parseRetryAfter(header);
438
+ return seconds === void 0 ? {} : { retryAfterSeconds: seconds };
439
+ }
440
+ /**
441
+ * JSON for console breadcrumbs, tolerating cycles.
442
+ *
443
+ * @param value - Any value.
444
+ * @returns Text.
445
+ */
446
+ function safeJson(value) {
447
+ try {
448
+ return JSON.stringify(value) ?? String(value);
449
+ } catch {
450
+ return String(value);
451
+ }
452
+ }
453
+ /**
454
+ * Capture an error with the client from `init`.
455
+ *
456
+ * @param error - Thrown value.
457
+ * @param context - Extra context.
458
+ * @returns Event id, if sent.
459
+ */
460
+ function captureException(error, context) {
461
+ return current?.captureException(error, context);
462
+ }
463
+ /**
464
+ * Capture a message with the client from `init`.
465
+ *
466
+ * @param message - Message.
467
+ * @param level - Level.
468
+ * @param context - Extra context.
469
+ * @returns Event id, if sent.
470
+ */
471
+ function captureMessage(message, level, context) {
472
+ return current?.captureMessage(message, level, context);
473
+ }
474
+ /**
475
+ * The client created by `init`, if any.
476
+ *
477
+ * @returns Current client.
478
+ */
479
+ function getClient() {
480
+ return current;
481
+ }
482
+ /**
483
+ * Record a product analytics event with the client from `init`.
484
+ *
485
+ * @param name - Event name, such as `signed_up`.
486
+ * @param properties - Event properties.
487
+ * @param options - Person override and person properties.
488
+ * @returns Event id, if sent.
489
+ */
490
+ function capture(name, properties, options) {
491
+ return current?.capture(name, properties, options);
492
+ }
493
+ /**
494
+ * Name the current person with your app's user id (after sign-in).
495
+ *
496
+ * @param distinctId - Your user id.
497
+ * @param set - Person properties, such as `{ email, plan }`.
498
+ * @param setOnce - Person properties to keep from the first time they are set.
499
+ */
500
+ function identify(distinctId, set, setOnce) {
501
+ current?.identify(distinctId, set, setOnce);
502
+ }
503
+ /**
504
+ * Forget the current person (on sign-out).
505
+ */
506
+ function reset() {
507
+ current?.reset();
508
+ }
509
+ /**
510
+ * Whether a flag is on for the current person, with the client from `init` (needs `flags: true`).
511
+ *
512
+ * @param key - Flag key.
513
+ * @returns On, off, or undefined before flags load.
514
+ */
515
+ function isFeatureEnabled(key) {
516
+ return current?.isFeatureEnabled(key);
517
+ }
518
+ /**
519
+ * A flag's value for the current person, with the client from `init` (needs `flags: true`).
520
+ *
521
+ * @param key - Flag key.
522
+ * @returns `false`, `true`, the variant key, or undefined before flags load.
523
+ */
524
+ function getFeatureFlag(key) {
525
+ return current?.getFeatureFlag(key);
526
+ }
527
+ /**
528
+ * Run a callback whenever flags load, with the client from `init`.
529
+ *
530
+ * @param listener - Called with every flag.
531
+ * @returns A function that stops it.
532
+ */
533
+ function onFeatureFlags(listener) {
534
+ return current?.onFeatureFlags(listener) ?? function nothing() {};
535
+ }
536
+ //#endregion
537
+ export { capture, captureException, captureMessage, getClient, getFeatureFlag, identify, init, isFeatureEnabled, onFeatureFlags, reset };
@@ -0,0 +1,61 @@
1
+ //#region src/replay.d.ts
2
+ export interface ReplayOptions {
3
+ /**
4
+ * Share of sessions recorded, decided once per session. Default 1.
5
+ */
6
+ sampleRate?: number;
7
+ /**
8
+ * Replace what people type with asterisks. Default true.
9
+ */
10
+ maskAllInputs?: boolean;
11
+ /**
12
+ * Elements whose text is masked, such as `[data-private]`.
13
+ */
14
+ maskTextSelector?: string;
15
+ /**
16
+ * Elements left out of the recording entirely. Default `.chaja-block`.
17
+ */
18
+ blockSelector?: string;
19
+ /**
20
+ * How often recorded events are sent. Default 5 seconds.
21
+ */
22
+ flushIntervalMs?: number;
23
+ /**
24
+ * The window to record (tests pass a fake one).
25
+ */
26
+ window?: Window & typeof globalThis;
27
+ /**
28
+ * The recorder (tests pass a fake one).
29
+ */
30
+ recorder?: Recorder;
31
+ }
32
+ /**
33
+ * The part of rrweb's `record` the replay uses.
34
+ */
35
+ export interface Recorder {
36
+ (options: {
37
+ emit: (event: unknown) => void;
38
+ maskAllInputs?: boolean;
39
+ maskTextSelector?: string;
40
+ blockSelector?: string;
41
+ recordCanvas?: boolean;
42
+ inlineStylesheet?: boolean;
43
+ }): (() => void) | undefined;
44
+ takeFullSnapshot?: (isCheckout?: boolean) => void;
45
+ }
46
+ /**
47
+ * Record the page as a session replay with the client from `init`: the DOM and what people do with it, sent in chunks to
48
+ * Chajá under the analytics session, so a replay sits next to that session's events and errors. Inputs are masked by
49
+ * default; add `chaja-block` to an element to leave it out.
50
+ *
51
+ * @param options - Sampling, privacy and timing.
52
+ * @returns A function that stops recording and sends what is left.
53
+ * @example
54
+ * import { init } from "@chaja/sdk-browser";
55
+ * import { startReplay } from "@chaja/sdk-browser/replay";
56
+ *
57
+ * init({ endpoint, key });
58
+ * startReplay({ sampleRate: 0.5 });
59
+ */
60
+ export declare function startReplay(options?: ReplayOptions): () => void;
61
+ //#endregion
@@ -0,0 +1,146 @@
1
+ import { getClient } from "./index.mjs";
2
+ import { record } from "@rrweb/record";
3
+ //#region src/replay.ts
4
+ const MAX_BUFFER = 500;
5
+ const KEEPALIVE_LIMIT = 6e4;
6
+ /**
7
+ * Compress a body with gzip where the browser can.
8
+ *
9
+ * @param view - Window.
10
+ * @param body - JSON text.
11
+ * @returns Body and its encoding.
12
+ */
13
+ async function compress(view, body) {
14
+ if (typeof view.CompressionStream !== "function") return {
15
+ body,
16
+ gzip: false
17
+ };
18
+ const stream = new Blob([body]).stream().pipeThrough(new view.CompressionStream("gzip"));
19
+ return {
20
+ body: await new Response(stream).arrayBuffer(),
21
+ gzip: true
22
+ };
23
+ }
24
+ /**
25
+ * Record the page as a session replay with the client from `init`: the DOM and what people do with it, sent in chunks to
26
+ * Chajá under the analytics session, so a replay sits next to that session's events and errors. Inputs are masked by
27
+ * default; add `chaja-block` to an element to leave it out.
28
+ *
29
+ * @param options - Sampling, privacy and timing.
30
+ * @returns A function that stops recording and sends what is left.
31
+ * @example
32
+ * import { init } from "@chaja/sdk-browser";
33
+ * import { startReplay } from "@chaja/sdk-browser/replay";
34
+ *
35
+ * init({ endpoint, key });
36
+ * startReplay({ sampleRate: 0.5 });
37
+ */
38
+ function startReplay(options = {}) {
39
+ const client = getClient();
40
+ if (!client) return function nothing() {};
41
+ const view = options.window ?? window;
42
+ const recorder = options.recorder ?? record;
43
+ const url = `${client.options.endpoint.replace(/\/+$/u, "")}/api/v1/replay`;
44
+ const sampleRate = options.sampleRate ?? 1;
45
+ const state = {
46
+ session: client.getSessionId() ?? "",
47
+ seq: 0,
48
+ sampled: false,
49
+ buffer: []
50
+ };
51
+ /**
52
+ * Whether this session is recorded, decided once and kept for the tab.
53
+ *
54
+ * @param session - Session id.
55
+ * @returns True when recorded.
56
+ */
57
+ function sampled(session) {
58
+ const key = `chaja_replay_${session}`;
59
+ try {
60
+ const stored = view.sessionStorage.getItem(key);
61
+ if (stored !== null) return stored === "1";
62
+ const decision = Math.random() < sampleRate;
63
+ view.sessionStorage.setItem(key, decision ? "1" : "0");
64
+ return decision;
65
+ } catch {
66
+ return Math.random() < sampleRate;
67
+ }
68
+ }
69
+ state.sampled = sampled(state.session);
70
+ /**
71
+ * Send buffered events as the next chunk of their session.
72
+ *
73
+ * @param final - The page is going away: keep the request alive.
74
+ */
75
+ async function flush(final = false) {
76
+ const events = state.buffer.splice(0);
77
+ if (events.length === 0 || !state.sampled || !client) return;
78
+ const chunk = JSON.stringify({
79
+ sessionId: state.session,
80
+ distinctId: client.getDistinctId(),
81
+ seq: state.seq,
82
+ events
83
+ });
84
+ state.seq += 1;
85
+ try {
86
+ const payload = final ? {
87
+ body: chunk,
88
+ gzip: false
89
+ } : await compress(view, chunk);
90
+ await view.fetch(url, {
91
+ method: "POST",
92
+ headers: {
93
+ "content-type": "application/json",
94
+ "x-chaja-key": client.options.key,
95
+ ...payload.gzip ? { "content-encoding": "gzip" } : {}
96
+ },
97
+ body: payload.body,
98
+ keepalive: final && chunk.length < KEEPALIVE_LIMIT
99
+ });
100
+ } catch {}
101
+ }
102
+ /**
103
+ * Flush, first moving to a new session when the old one expired, with a full snapshot so the new replay starts whole.
104
+ */
105
+ function tick() {
106
+ if (!client) return;
107
+ const session = client.getSessionId() ?? state.session;
108
+ if (session === state.session) {
109
+ flush();
110
+ return;
111
+ }
112
+ flush();
113
+ state.session = session;
114
+ state.seq = 0;
115
+ state.sampled = sampled(session);
116
+ recorder.takeFullSnapshot?.(true);
117
+ }
118
+ const stopRecording = recorder({
119
+ emit(event) {
120
+ if (!state.sampled) return;
121
+ state.buffer.push(event);
122
+ if (state.buffer.length >= MAX_BUFFER) flush();
123
+ },
124
+ maskAllInputs: options.maskAllInputs ?? true,
125
+ blockSelector: options.blockSelector ?? ".chaja-block",
126
+ ...options.maskTextSelector ? { maskTextSelector: options.maskTextSelector } : {},
127
+ recordCanvas: false,
128
+ inlineStylesheet: true
129
+ });
130
+ const timer = view.setInterval(tick, options.flushIntervalMs ?? 5e3);
131
+ /**
132
+ * Send what is left when the page is hidden.
133
+ */
134
+ function onHide() {
135
+ flush(true);
136
+ }
137
+ view.addEventListener("pagehide", onHide);
138
+ return function stop() {
139
+ stopRecording?.();
140
+ view.clearInterval(timer);
141
+ view.removeEventListener("pagehide", onHide);
142
+ flush(true);
143
+ };
144
+ }
145
+ //#endregion
146
+ export { startReplay };
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@chaja/sdk-browser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Chajá error tracking for browsers: global handlers, breadcrumbs and sendBeacon delivery.",
5
+ "keywords": [
6
+ "chaja",
7
+ "error-tracking",
8
+ "observability",
9
+ "browser",
10
+ "sdk"
11
+ ],
12
+ "homepage": "https://github.com/emiliodominguez/chaja#readme",
13
+ "bugs": "https://github.com/emiliodominguez/chaja/issues",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/emiliodominguez/chaja.git",
17
+ "directory": "packages/sdk-browser"
18
+ },
19
+ "type": "module",
20
+ "nx": {
21
+ "tags": [
22
+ "scope:sdk",
23
+ "type:sdk"
24
+ ]
25
+ },
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.mts",
29
+ "default": "./dist/index.mjs"
30
+ },
31
+ "./replay": {
32
+ "types": "./dist/replay.d.mts",
33
+ "default": "./dist/replay.mjs"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "sideEffects": false,
40
+ "peerDependencies": {
41
+ "@rrweb/record": "^2.1.7"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@rrweb/record": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@chaja/protocol": "0.0.0",
50
+ "@rrweb/record": "2.1.7",
51
+ "tsdown": "0.23.0"
52
+ },
53
+ "dependencies": {
54
+ "@chaja/sdk-core": "0.2.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsdown src/index.ts src/replay.ts --format esm --dts --clean --out-dir dist",
58
+ "typecheck": "tsc --noEmit -p ."
59
+ }
6
60
  }