@maple-dev/browser 0.8.0 → 0.10.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
@@ -19,6 +19,7 @@ import { MapleBrowser } from "@maple-dev/browser"
19
19
  MapleBrowser.init({
20
20
  ingestKey: "maple_pk_...", // public ingest key
21
21
  serviceName: "acme-web",
22
+ region: "eu", // "us" (default) or "eu"; must match your organization's region
22
23
  environment: "production",
23
24
  replay: { enabled: true, sampleRate: 1.0 },
24
25
  privacy: { maskAllInputs: true },
@@ -35,7 +36,46 @@ That single call:
35
36
  only once a session is sampled in, so a `sampleRate` below 1 costs the
36
37
  unsampled visitors nothing beyond the base SDK (see [Bundle size](#bundle-size));
37
38
  - writes session metadata at start (`active`) and on page hide (`ended`),
38
- including the trace ids observed during the session.
39
+ including the trace ids observed during the session;
40
+ - captures uncaught errors and unhandled promise rejections as error spans, so
41
+ browser crashes reach Maple's error tracking.
42
+
43
+ ## Errors
44
+
45
+ Every uncaught error and unhandled rejection becomes a span with status `Error`
46
+ and an `exception` event, which is the shape Maple fingerprints — so browser
47
+ crashes group beside your server-side errors instead of in a silo.
48
+
49
+ Errors your app _catches_ never reach the global handlers, because catching them
50
+ is what stops them. Report those explicitly:
51
+
52
+ ```ts
53
+ try {
54
+ render()
55
+ } catch (error) {
56
+ MapleBrowser.captureException(error, { name: "browser.render_error" })
57
+ }
58
+ ```
59
+
60
+ Opt out of the global handlers with `tracing: { captureErrors: false }` — worth
61
+ doing only when another tracker already owns them, or the same crash is recorded
62
+ twice.
63
+
64
+ A cross-origin script reports to the browser as a bare `"Script error."` with no
65
+ stack and no filename. Those are dropped rather than recorded: they all
66
+ fingerprint to one contentless issue that buries the real ones. Add
67
+ `crossorigin` to the script tag to get the real error instead.
68
+
69
+ ### Failed HTTP requests
70
+
71
+ `errors: { captureHttpStatus: [[500, 599]] }` makes those `fetch`/XHR responses errors, typed by
72
+ status. By default a response status alone is not an error; a network failure always is.
73
+
74
+ ### Breadcrumbs
75
+
76
+ The last 50 clicks, inputs, navigations and console lines are kept in memory and exported, as OTel
77
+ log records linked to the error's span, only when an error is recorded. `breadcrumbs: false` turns
78
+ this off; `logs: { captureConsole: ["warn", "error"] }` sends those console levels as logs right away.
39
79
 
40
80
  ## Bundle size
41
81
 
@@ -43,8 +83,9 @@ Bundled, minified and gzipped, as your bundler would ship it:
43
83
 
44
84
  | | gzipped | what it is |
45
85
  | ---------------- | ------- | --------------------------------------------------------- |
46
- | **eager** | ~32 kB | every page load, before any sampling decision |
47
- | ↳ our code alone | ~3.5 kB | the marginal cost if your app already ships OpenTelemetry |
86
+ | **eager** | ~40 kB | every page load, before any sampling decision |
87
+ | ↳ our code alone | ~16 kB | the marginal cost if your app already ships OpenTelemetry |
88
+ | **deferred** | ~9 kB | logs SDK, Web Vitals, document timing, after `init()` |
48
89
  | **lazy** | ~61 kB | rrweb — downloaded only by sessions sampled into replay |
49
90
 
50
91
  The eager figure is ~90% OpenTelemetry. If your app already uses the OTel web
@@ -89,6 +130,96 @@ a separate analytics silo. Calls before `init()` finishes are queued.
89
130
  MapleBrowser.track("checkout_completed", { plan: "pro", seats: 12 })
90
131
  ```
91
132
 
133
+ ## Logs
134
+
135
+ `MapleBrowser.logger` writes OpenTelemetry log records, linked to the active span and the session.
136
+ Calls before `init()` are queued.
137
+
138
+ ```ts
139
+ MapleBrowser.logger.info("checkout started", { "cart.items": 3 })
140
+ ```
141
+
142
+ ## Web Vitals
143
+
144
+ LCP, CLS, INP, FCP and TTFB are reported as `browser.web_vital` OpenTelemetry log events
145
+ (browser semantic conventions), linked to the `pageload` span and the session. Opt out with
146
+ `webVitals: false`.
147
+
148
+ ## Replay on error
149
+
150
+ `replay: { sampleRate: 0.05, onErrorSampleRate: 1 }` records 5% of sessions, and has every other
151
+ session keep the last minute in memory, uploading it and recording the rest of the session only if
152
+ an error is recorded.
153
+
154
+ ## Trace sampling
155
+
156
+ `tracing: { sampleRate: 0.25 }` exports the traces of ~25% of sessions. The decision is per
157
+ session, so a sampled session keeps all of its traces. Reported errors (uncaught errors, unhandled
158
+ rejections, `captureException`) are always exported.
159
+
160
+ ## Regions
161
+
162
+ Maple runs separate US and EU instances, and an ingest key only works in the
163
+ region it was created in. Set `region: "eu"` for an organization on
164
+ `app.eu.maple.dev`; the SDK then sends to `https://ingest.eu.maple.dev`. An
165
+ explicit `endpoint` (a proxy, or self-hosted ingest) always wins over `region`.
166
+
167
+ ## Tracing across origins
168
+
169
+ `fetch` and XHR spans carry the W3C `traceparent` header to same-origin requests only.
170
+ When your API lives on another origin, list it so browser and backend spans
171
+ join one trace, and allow the `traceparent` header in the API's CORS policy:
172
+
173
+ ```ts
174
+ MapleBrowser.init({
175
+ // ...
176
+ tracing: { propagateTraceHeaderCorsUrls: [/^https:\/\/api\.example\.com\//] },
177
+ })
178
+ ```
179
+
180
+ ## Navigations and data loading
181
+
182
+ Call these from your router's hooks to make one click one trace: a navigation
183
+ span, the data-loading spans under it, and the `fetch` spans they start.
184
+ [maple.dev/docs/frontend](https://maple.dev/docs/frontend) shows where for each
185
+ framework.
186
+
187
+ ```ts
188
+ MapleBrowser.startNavigation(location.pathname) // a navigation starts
189
+ const data = await MapleBrowser.traced("loader /projects/:id", () => load(id), {
190
+ isFailure: (error) => !isRedirect(error), // redirects aren't failures
191
+ })
192
+ MapleBrowser.endNavigation("/projects/:id") // the route is ready: its template
193
+ ```
194
+
195
+ - The first `startNavigation` opens a `pageload` span, joined to the server
196
+ render's trace from a `Server-Timing: traceparent;desc="…"` entry or a
197
+ `<meta name="traceparent">` tag; later calls open `navigate` spans, and end
198
+ one still open as `app.navigation.interrupted`.
199
+ - The document's `pageload` starts at navigation start, and gets child spans
200
+ from the Navigation Timing entry once the page has loaded: `documentFetch`
201
+ (with `dns`, `connect`, `request` and `response` under it), `domProcessing`
202
+ and `loadEvent`.
203
+ - `traced` returns `fn`'s result and rethrows its error unchanged. Only requests
204
+ started before `fn`'s first `await` nest under its span. An error it recorded
205
+ isn't reported again by `captureException` or the global handlers.
206
+ - All three are no-ops on the server, before `init()`, with tracing disabled or
207
+ without consent (`traced` then only runs `fn`).
208
+
209
+ ## React
210
+
211
+ `@maple-dev/browser/react` has `MapleErrorBoundary`, `mapleReactErrorHandler()` for React 19's
212
+ `createRoot` error options, and router adapters that call `startNavigation`/`endNavigation` for you:
213
+
214
+ ```ts
215
+ import { instrumentReactRouter, instrumentTanStackRouter } from "@maple-dev/browser/react"
216
+
217
+ instrumentReactRouter(createBrowserRouter(routes)) // navigate /projects/:id
218
+ instrumentTanStackRouter(router) // navigate /projects/$projectId
219
+ ```
220
+
221
+ Attach them after `MapleBrowser.init`: a navigation reported earlier uses up the page load.
222
+
92
223
  ## Linking a marketing site to your app
93
224
 
94
225
  The visitor id lives in localStorage **and** a cookie scoped to your registered
@@ -103,6 +234,18 @@ stay per-origin; `VisitorId` is the join key. Override the scope with
103
234
  attribute hooks (`data-rr-block`, `.rr-block`, `.rr-ignore`) to block elements
104
235
  or subtrees from capture.
105
236
 
237
+ URLs are redacted before they leave the page: the values of credential-shaped
238
+ query and fragment parameters (`token`, `code`, `access_token`, `password`, …)
239
+ become `REDACTED` in session rows, events, network events, replay meta events
240
+ and span attributes. Add your own rewriting with `privacy.sanitizeUrl`, for
241
+ example to collapse ids in paths:
242
+
243
+ ```ts
244
+ privacy: {
245
+ sanitizeUrl: (url) => url.replace(/\/users\/\d+/, "/users/:id")
246
+ }
247
+ ```
248
+
106
249
  `privacy.requireConsent` holds all capture until `MapleBrowser.setConsent(true)`.
107
250
  Global Privacy Control is honored by default and suppresses the persistent
108
251
  visitor id; `doNotTrack` is not, unless `privacy.respectDoNotTrack` is set.
@@ -0,0 +1,73 @@
1
+ import { O as safeEmit } from "./sink-BOR9_z4b.mjs";
2
+ //#region ../browser-session/src/replay/capture/console.ts
3
+ const LEVELS = [
4
+ "log",
5
+ "info",
6
+ "warn",
7
+ "error",
8
+ "debug"
9
+ ];
10
+ const MAX_MESSAGE = 2e3;
11
+ /** No array logged into a 2,000-character message needs more elements than this. */
12
+ const MAX_ARRAY_ELEMENTS = 100;
13
+ /**
14
+ * Capture `console.*` calls as session events. Wraps each method, emits a
15
+ * distilled record, then forwards to the original so the host app's console
16
+ * behaves normally. Never throws into the call site.
17
+ */
18
+ function installConsoleCapture(emit) {
19
+ const original = {};
20
+ const wrappers = {};
21
+ let active = true;
22
+ for (const level of LEVELS) {
23
+ const orig = console[level];
24
+ original[level] = orig;
25
+ const wrapper = (...args) => {
26
+ if (active) safeEmit(emit, {
27
+ type: "console",
28
+ level,
29
+ message: formatArgs(args)
30
+ });
31
+ orig.apply(console, args);
32
+ };
33
+ wrappers[level] = wrapper;
34
+ console[level] = wrapper;
35
+ }
36
+ return () => {
37
+ active = false;
38
+ for (const level of LEVELS) {
39
+ const orig = original[level];
40
+ if (orig && console[level] === wrappers[level]) console[level] = orig;
41
+ }
42
+ };
43
+ }
44
+ function formatArgs(args) {
45
+ const text = args.map((a) => {
46
+ if (typeof a === "string") return a;
47
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
48
+ try {
49
+ return boundedStringify(a, MAX_MESSAGE) ?? String(a);
50
+ } catch {
51
+ return String(a);
52
+ }
53
+ }).join(" ");
54
+ return text.length > MAX_MESSAGE ? `${text.slice(0, MAX_MESSAGE)}…` : text;
55
+ }
56
+ /**
57
+ * `JSON.stringify` that stops descending once roughly `budget` characters have
58
+ * been produced. The message is cut to `MAX_MESSAGE` anyway, and serializing a
59
+ * whole store object on every `console.log` first was a main-thread cost paid
60
+ * on the host app's hot path. A replacer returning `undefined` skips a value
61
+ * without visiting its children, so the walk is bounded by the budget.
62
+ */
63
+ function boundedStringify(value, budget) {
64
+ let used = 0;
65
+ return JSON.stringify(value, (key, nested) => {
66
+ if (used > budget) return void 0;
67
+ used += key.length + (typeof nested === "string" ? nested.length : 4);
68
+ if (Array.isArray(nested) && nested.length > MAX_ARRAY_ELEMENTS) return nested.slice(0, MAX_ARRAY_ELEMENTS);
69
+ return nested;
70
+ });
71
+ }
72
+ //#endregion
73
+ export { installConsoleCapture as t };