@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 +146 -3
- package/dist/console-BrjBnOiX.mjs +73 -0
- package/dist/deferred-CdtlxG0Q.mjs +723 -0
- package/dist/index.d.mts +233 -30
- package/dist/index.mjs +1 -640
- package/dist/react.d.mts +88 -0
- package/dist/react.mjs +130 -0
- package/dist/replay-session-DdVYdHc_.mjs +527 -0
- package/dist/{sink-D9w1kg0Q.mjs → sink-BOR9_z4b.mjs} +733 -177
- package/dist/src-Bzmp8rVr.mjs +1722 -0
- package/package.json +37 -14
- package/dist/replay-session-BYZfi3iZ.mjs +0 -327
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** | ~
|
|
47
|
-
| ↳ our code alone | ~
|
|
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 };
|