@groundcover/browser 0.1.0 → 1.0.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,222 +1,242 @@
1
- # groundcover RUM Browser Monitoring
1
+ <p align="center">
2
+ <img src="./assets/logo.png" alt="groundcover" width="240" />
3
+ </p>
2
4
 
3
- groundcover’s Real User Monitoring (RUM) SDK allows you to capture front-end performance data, user events, and errors from your web applications.
5
+ <h1 align="center">RUM — Browser SDK</h1>
4
6
 
5
- See the dedicated [groundcover documentation](https://docs.groundcover.com/capabilities/real-user-monitoring-rum/) for more details.
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@groundcover/browser"><img src="https://img.shields.io/npm/v/@groundcover/browser.svg" alt="npm version" /></a>
9
+ <a href="https://bundlejs.com/?q=@groundcover/browser"><img src="https://img.shields.io/bundlejs/size/@groundcover/browser" alt="minzipped size" /></a>
10
+ <a href="https://github.com/groundcover-com/rum/actions/workflows/ci.yml"><img src="https://github.com/groundcover-com/rum/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
11
+ </p>
6
12
 
7
- ## Installation
13
+ groundcover’s Real User Monitoring (RUM) SDK captures front-end **performance**, **user
14
+ interactions**, **errors**, **logs**, **distributed traces**, and **session replay** from your web
15
+ application — with privacy masking **on by default**.
8
16
 
9
- ```bash
10
- npm install @groundcover/browser
11
- ```
17
+ > Session replay is powered by [**rrweb**](https://github.com/rrweb-io/rrweb) under the hood.
12
18
 
13
- ## Initialize the SDK
19
+ See the [groundcover RUM documentation](https://docs.groundcover.com/capabilities/real-user-monitoring-rum/)
20
+ for the full product guide.
14
21
 
15
- ```ts
16
- import groundcover from "@groundcover/browser";
22
+ ---
17
23
 
18
- groundcover.init({
19
- apiKey: "your-api-key",
20
- cluster: "your-cluster",
21
- environment: "production",
22
- dsn: "your-dsn",
23
- appId: "your-app-id",
24
- });
25
- ```
24
+ ## Table of contents
26
25
 
27
- ### Micro-frontend Session Synchronization
26
+ - [Quick start](#quick-start)
27
+ - [Configuration reference](#configuration-reference)
28
+ - [Privacy & data masking](#privacy--data-masking)
29
+ - [Session replay](#session-replay)
30
+ - [Architecture](#architecture)
31
+ - [Web Worker offloading](#web-worker-offloading)
32
+ - [Session lifecycle](#session-lifecycle)
33
+ - [Performance considerations](#performance-considerations)
34
+ - [API reference](#api-reference)
35
+ - [Migrating to 1.0.0](#migrating-to-100)
28
36
 
29
- For micro-frontend architectures, you can synchronize sessions across different frontend applications by passing a shared sessionId:
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ npm install @groundcover/browser
43
+ ```
30
44
 
31
45
  ```ts
32
- // Main application generates a sessionId
33
- const sharedSessionId = "session-12345";
46
+ import groundcover from "@groundcover/browser";
34
47
 
35
- // Initialize SDK in main app
36
48
  groundcover.init({
37
49
  apiKey: "your-api-key",
38
- cluster: "your-cluster",
39
- environment: "production",
40
50
  dsn: "your-dsn",
41
- appId: "your-app-id",
42
- sessionId: sharedSessionId, // Use shared sessionId
43
- });
44
-
45
- // Initialize SDK in micro-frontend with same sessionId
46
- groundcover.init({
47
- apiKey: "your-api-key",
48
51
  cluster: "your-cluster",
52
+ appId: "your-app-id",
49
53
  environment: "production",
50
- dsn: "your-dsn",
51
- appId: "micro-frontend-app-id",
52
- sessionId: sharedSessionId, // Same sessionId for session continuity
53
54
  });
54
55
  ```
55
56
 
56
- ## Identify users
57
-
58
- Link RUM data to specific users:
57
+ That single call installs every instrumentation (page loads, DOM interactions, network requests,
58
+ errors, console logs, navigation, performance, and session replay) and starts sending data. From
59
+ here you can enrich it:
59
60
 
60
61
  ```ts
61
- groundcover.identifyUser({
62
- id: "John Doe",
63
- email: "john@groundcover.com",
64
- organization: "groundcover",
65
- });
66
- ```
62
+ // Tie events to a user
63
+ groundcover.identifyUser({ id: "u_123", email: "john@acme.com", organization: "acme" });
67
64
 
68
- ## Send custom events
65
+ // Capture a handled error
66
+ groundcover.captureException(new Error("Checkout failed"), { feature: "checkout" });
69
67
 
70
- Send custom events to groundcover:
68
+ // Emit a structured log
69
+ groundcover.logger.warn("Payment retry", { provider: "stripe", attempt: 2 });
71
70
 
72
- ```ts
73
- groundcover.track("custom-event");
71
+ // Emit a custom business event
72
+ groundcover.sendCustomEvent({ event: "plan_upgraded", attributes: { plan: "pro" } });
74
73
  ```
75
74
 
76
- ## Capture structured logs
75
+ ---
77
76
 
78
- Use the `logger` API to send log messages with structured attributes that become queryable fields on the backend. The API mirrors Sentry's [`Sentry.logger`](https://docs.sentry.io/platforms/javascript/logs/) pattern — one method per level, second arg is the attributes object:
77
+ ## Configuration reference
78
+
79
+ `init()` takes connection/identity fields at the top level and all behavioral knobs under
80
+ `options`, grouped by concern.
79
81
 
80
82
  ```ts
81
- groundcover.logger.warn("Checkout failed", {
82
- orderId: "ord_42",
83
- region: "us-east",
84
- cart: { items: 3, total: 99.99 },
85
- });
83
+ groundcover.init({
84
+ // ── Connection & identity ──
85
+ apiKey, dsn, cluster, appId,
86
+ environment, namespace, releaseId,
87
+ user, sessionId,
86
88
 
87
- groundcover.logger.info("User signed in", { userId: "u_123" });
88
- groundcover.logger.error("Payment gateway timeout", { provider: "stripe" });
89
+ options: {
90
+ debug,
91
+ sessionSampleRate, eventSampleRate,
92
+ enabledEvents, excludedUrls,
93
+ sessionMaxDuration,
94
+ beforeSend, enrichEvent,
95
+ privacy: { /* … */ },
96
+ tracing: { /* … */ },
97
+ transport: { /* … */ },
98
+ replay: { /* … */ },
99
+ },
100
+ });
89
101
  ```
90
102
 
91
- Available levels: `log`, `info`, `warn`, `error`, `debug`, `trace`. Nested attribute objects are automatically flattened to dotted keys (e.g. `cart.items`, `cart.total`).
103
+ ### Top-level (connection & identity)
92
104
 
93
- ### Structured `console.*` capture
105
+ | Field | Type | Required | Description |
106
+ |---|---|---|---|
107
+ | `apiKey` | `string` | ✅ | Your groundcover RUM API key. |
108
+ | `dsn` | `string` | ✅ | RUM intake endpoint. A bare host is upgraded to `https://`. |
109
+ | `cluster` | `string` | ✅ | Target groundcover cluster. |
110
+ | `appId` | `string` | ✅ | Application identifier; reported as `service.name`. |
111
+ | `environment` | `string` | — | Deployment environment (e.g. `production`, `staging`). |
112
+ | `namespace` | `string` | — | Logical grouping/namespace for the app. |
113
+ | `releaseId` | `string` | — | Release/version identifier (e.g. a semver or commit hash). |
114
+ | `user` | `Partial<UserIdentifiers>` | — | Identify the user at init (same shape as [`identifyUser`](#identifyuser)). |
115
+ | `sessionId` | `string` | — | Use a shared session id — see [micro-frontends](#micro-frontend-session-synchronization). |
116
+ | `options` | `Partial<SDKOptions>` | — | Behavioral configuration, below. |
117
+
118
+ ### `options` — top-level knobs
119
+
120
+ | Option | Type | Default | Description |
121
+ |---|---|---|---|
122
+ | `debug` | `boolean` | `false` | Verbose SDK console logging. |
123
+ | `sessionSampleRate` | `number` | `1` | Fraction of sessions captured (0–1). |
124
+ | `eventSampleRate` | `number` | `1` | Fraction of events captured (0–1). |
125
+ | `enabledEvents` | `Array<…>` | `[]` | Instrumentations to enable. **Empty = all.** Members: `dom`, `network`, `exceptions`, `logs`, `pageload`, `navigation`, `performance`, `replay`. |
126
+ | `excludedUrls` | `Array<string \| RegExp>` | — | Network requests matching these are not captured. |
127
+ | `sessionMaxDuration` | `number` | `4h` | Target max session length in ms (1 min – 8 h). See [session lifecycle](#session-lifecycle). |
128
+ | `beforeSend` | `(event) => boolean` | — | Drop gate — return `false` to discard an event. Runs after redaction/enrichment; see [event lifecycle](#architecture). |
129
+ | `enrichEvent` | `(event) => event` | — | Mutate/replace an event before batching. Runs right after `beforeSend`; see [event lifecycle](#architecture). |
94
130
 
95
- The SDK auto-captures `console.log` / `info` / `warn` / `error` / `debug` / `trace` calls. When any argument is a plain object, its keys are promoted to structured log attributes alongside the stringified message — no code changes required:
131
+ ### `options.privacy`
96
132
 
97
- ```ts
98
- console.log("User action emitted", {
99
- appId: "web",
100
- environment: "prod",
101
- userAction: { type: "click", target: "checkout-button" },
102
- });
103
- // → log event attributes include: appId, environment, userAction.type, userAction.target
104
- ```
133
+ Drives both replay and non-replay masking. **On by default.** See
134
+ [Privacy & data masking](#privacy--data-masking) for the full table.
105
135
 
106
- Reserved keys (`message`, `level`, `location`) cannot be overridden by user-supplied attributes — `message` and `level` are stamped by the SDK, and `location` is always set from the current browser URL/path/title.
136
+ ### `options.tracing`
107
137
 
108
- ## Capture errors
138
+ Distributed-tracing header propagation for outgoing requests.
109
139
 
110
- Capture errors from your web application:
140
+ | Option | Type | Default | Description |
141
+ |---|---|---|---|
142
+ | `propagationUrls` | `string[]` | `[]` | Request URLs (prefix/`*`-glob match) that receive injected tracing headers. |
143
+ | `propagationHeaders` | `string[]` | `[]` | Header names to read/propagate onto traced requests. |
144
+ | `traceIdHeaderName` | `string` | `""` | Header carrying the trace id. |
145
+ | `spanIdHeaderName` | `string` | `""` | Header carrying the span id. |
146
+ | `origin` | `{ name: string; value: string }` | `{ name: "", value: "" }` | Origin tag stamped on injected trace headers. |
111
147
 
112
148
  ```ts
113
- groundcover.captureException(new Error("Something went wrong"));
149
+ options: {
150
+ tracing: {
151
+ propagationUrls: ["https://api.acme.com/*"],
152
+ traceIdHeaderName: "x-groundcover-trace-id",
153
+ spanIdHeaderName: "x-groundcover-span-id",
154
+ },
155
+ }
114
156
  ```
115
157
 
116
- You can also attach metadata to provide additional context:
158
+ ### `options.transport`
117
159
 
118
- ```ts
119
- groundcover.captureException(new Error("Payment failed"), {
120
- userId: "123",
121
- feature: "checkout",
122
- cart: { items: 3, total: 99.99 },
123
- });
124
- ```
125
-
126
- ## Optional Configuration
127
-
128
- You can customize SDK behavior (event sampling, data masking, enabled events). The following properties are customizable:
129
-
130
- ```typescript
131
- export interface SDKOptions {
132
- batchSize: number;
133
- batchTimeout: number;
134
- eventSampleRate: number;
135
- sessionSampleRate: number;
136
- environment: string;
137
- debug: boolean;
138
- tracePropagationUrls: string[];
139
- sessionMaxDuration?: number; // optional; default 4h, range 1 minute – 8 hours (ms)
140
- privacy?: PrivacyOptions; // data masking; ON by default — see "Privacy & data masking"
141
- }
142
- ```
160
+ Batching and delivery of outgoing events.
143
161
 
144
- `sessionMaxDuration` sets a **target** maximum wall-clock session length (default 4 hours, must be between 1 minute and 8 hours).
162
+ | Option | Type | Default | Description |
163
+ |---|---|---|---|
164
+ | `batchSize` | `number` | `10` | Max events buffered before a batch flushes. |
165
+ | `batchTimeout` | `number` | `10000` | Max ms a batch waits before flushing regardless of size. |
166
+ | `compression` | `boolean` | `true` | Gzip-compress batches (offloaded to the Web Worker when available). |
145
167
 
146
- It is enforced **lazily, on activity — not by a background timer**, so it is **not a hard upper bound**. Once the cap has elapsed, the *next* user/business event (click, navigation, log, custom, network, exception, …) flushes pending events under the current session id, generates a fresh id, and resumes replay recording if it had been active.
168
+ ### `options.replay`
147
169
 
148
- Because rotation is activity-gated, a session that goes idle keeps its id past the cap until the next qualifying event:
170
+ Session-replay recording controls. **This is noise reduction, not privacy** — to mask sensitive
171
+ content use [`privacy`](#privacy--data-masking).
149
172
 
150
- - A dormant or backgrounded tab that produces no further events stays on the same session id — by design, so the SDK doesn't mint "phantom" sessions for tabs nobody is using.
151
- - **Session-replay batches don't count as activity** (rrweb emits a snapshot heartbeat every ~30s regardless of interaction), so a tab that's only recording replay won't rotate on that alone. Such a tab is ultimately bounded by the 30-minute replay-inactivity stop, not by this cap.
173
+ | Option | Type | Default | Description |
174
+ |---|---|---|---|
175
+ | `blockedSelectors` | `string[]` | — | CSS selectors whose elements (and subtrees) are excluded from recording. Useful for noisy extension-injected DOM (e.g. Grammarly). |
152
176
 
153
- The flush is best-effort and the rotation always proceeds regardless of delivery success. Values outside the allowed range or otherwise invalid fall back to the default with a `console.warn`.
177
+ > ⚠️ Don’t confuse `options.replay.blockedSelectors` (recording noise reduction) with
178
+ > `options.privacy.replay.*` (rrweb masking callbacks). They live at different levels and serve
179
+ > different purposes.
154
180
 
155
- ## Privacy & data masking
181
+ ### Updating config at runtime
156
182
 
157
- **Masking is ON by default.** With no `privacy` config the SDK runs at
158
- `privacy.level: 'mask-sensitive'`, which drives masking for both session replay
159
- and non-replay events (DOM / network / logs / errors).
183
+ `updateConfig` mirrors the `init` shape; nested groups merge one level deep (and `tracing.origin` /
184
+ `privacy.replay` one level deeper), so a partial update preserves the other keys in the group:
160
185
 
161
- ```typescript
162
- groundcover.init({
163
- /* … */
186
+ ```ts
187
+ groundcover.updateConfig({
164
188
  options: {
165
- privacy: {
166
- level: "mask-sensitive", // 'mask-sensitive' (default) | 'mask-all' | 'allow'
167
- maskSelectors: [".cc-number", "[data-secret]"], // always masked (replay + DOM)
168
- sensitiveKeys: ["account_number"], // extra case-insensitive key substrings
169
- // maskNetworkBodies / maskNetworkQueryParams / maskLogs / maskErrors: default true
170
- },
189
+ transport: { batchSize: 20 }, // batchTimeout/compression are preserved
190
+ privacy: { level: "mask-all" },
171
191
  },
172
192
  });
173
193
  ```
174
194
 
175
- ### Levels
176
-
177
- | Level | Inputs / keystrokes | Static page text | Network bodies, query params, logs, errors |
178
- |---|---|---|---|
179
- | `mask-sensitive` *(default)* | only **sensitive** form controls masked; all others captured | captured (except `data-private` / `maskSelectors` subtrees) | redacted by sensitive-key matching |
180
- | `mask-all` | **all** inputs masked | masked | redacted |
181
- | `allow` | captured | captured | not redacted |
195
+ For `user`, an omitted key leaves the current identity untouched, passing an object merges into it,
196
+ and passing `null` clears it (e.g. on logout).
182
197
 
183
- Under the default `mask-sensitive`, there are **two kinds of signal**.
198
+ ---
184
199
 
185
- **Explicit / container signals** — mask the element *and its whole subtree*,
186
- whether it holds a form input or static text:
187
-
188
- - it **or any ancestor** has a `data-private` attribute;
189
- - it **or any ancestor** matches one of `privacy.maskSelectors`;
190
- - one of its `id` / `name` / `class` / `aria-label` / `placeholder` attributes
191
- contains a legacy `maskFields` substring.
200
+ ## Privacy & data masking
192
201
 
193
- **Built-in field heuristic** — applies **only to form controls**
194
- (`<input>`, `<textarea>`, `<select>`), so ordinary static page text stays
195
- visible:
202
+ Masking is **on by default** (`privacy.level: 'mask-sensitive'`). A single `level` is the master
203
+ switch; finer toggles and hooks refine it.
196
204
 
197
- - it is `<input type="password">`;
198
- - one of its `id` / `name` / `class` / `aria-label` / `placeholder` attributes
199
- matches a built-in sensitive-key pattern (`password`, `secret`, `token`,
200
- `api-key`, `access-key`, `auth`/`authorization`, `bearer`, `credential`,
201
- `cvv`, `ssn`, `card-number`, …) or one of your `privacy.sensitiveKeys`.
205
+ | `level` | Replay inputs | Replay text | DOM events | Network / logs / errors |
206
+ |---|---|---|---|---|
207
+ | `mask-sensitive` **(default)** | sensitive masked | `[data-private]` + `maskSelectors` masked | sensitive masked | redacted |
208
+ | `mask-all` | masked | masked (`*`) | all masked | redacted |
209
+ | `allow` | — | — | — | off (auth headers are still stripped) |
202
210
 
203
- So a non-secret form control (search, name, description, date, numeric field) is
204
- captured with its real value, and a **static** element is never masked by the
205
- field heuristic alone — e.g. `<div id="token">` stays visible. To mask a
206
- sensitive **static** value, mark it with `data-private` or a `maskSelectors`
207
- entry (both also apply in session replay). Use `'mask-all'` to mask every input
208
- plus all static text, or `'allow'` to disable masking entirely.
211
+ Under `mask-sensitive`, an input/element is masked when it is a `type="password"`, sits under a
212
+ `[data-private]` ancestor or a `maskSelectors` match, or has an `id`/`name`/`class`/`aria-label`/
213
+ `placeholder` matching a built-in sensitive-key pattern or your `sensitiveKeys`. Non-sensitive inputs
214
+ and static page text stay visible; use `[data-private]` / `maskSelectors` to mask static content.
209
215
 
210
- For **session replay**, non-input/static text (e.g. a value in a
211
- `contenteditable` or a plain `<div>`) is masked when it sits under a
212
- `data-private` or `privacy.maskSelectors` subtree; the built-in field heuristic
213
- does not mask arbitrary text.
216
+ | Option | Type | Default | Description |
217
+ |---|---|---|---|
218
+ | `level` | `'mask-sensitive' \| 'mask-all' \| 'allow'` | `'mask-sensitive'` | Master masking switch. |
219
+ | `maskSelectors` | `string[]` | — | CSS selectors whose text/inputs are always masked (replay + DOM), unless `level: 'allow'`. |
220
+ | `maskNetworkBodies` | `boolean` | `true` | Redact network request/response bodies. |
221
+ | `maskNetworkQueryParams` | `boolean` | `true` | Redact URL query params. |
222
+ | `maskLogs` | `boolean` | `true` | Redact console/log messages and attributes. |
223
+ | `maskErrors` | `boolean` | `true` | Redact error messages, metadata and stack-frame URLs. |
224
+ | `sensitiveKeys` | `string[]` | — | Extra case-insensitive key substrings treated as sensitive, merged with built-ins. |
225
+ | `redact` | `(field) => value \| undefined` | — | Per-leaf custom redactor for non-replay events. Return `undefined` to defer to the SDK default. |
226
+ | `replay.maskTextFn` | `(text, el) => string` | — | rrweb `maskTextFn` pass-through (replay only). |
227
+ | `replay.maskInputFn` | `(text, el) => string` | — | rrweb `maskInputFn` pass-through (replay only). |
214
228
 
215
- Regardless of level (even `allow`), built-in sensitive request/response **headers**
216
- (`authorization`, `cookie`, `set-cookie`, and `token`/`key`/`secret`/`password`-like
217
- names) are always stripped.
229
+ ```ts
230
+ options: {
231
+ privacy: {
232
+ level: "mask-sensitive",
233
+ maskSelectors: [".pii", "#ssn"],
234
+ sensitiveKeys: ["account_no"],
235
+ },
236
+ }
237
+ ```
218
238
 
219
- ### Built-in sensitive patterns
239
+ #### Built-in sensitive patterns
220
240
 
221
241
  These are always treated as sensitive (case-insensitive); your `sensitiveKeys` are merged in on top.
222
242
 
@@ -231,58 +251,216 @@ These are always treated as sensitive (case-insensitive); your `sensitiveKeys` a
231
251
  - **Query / form param names** — matched as a whole key (not inside JSON bodies), for OAuth-style
232
252
  callbacks: `code`, `state`, `session_state`, `id_token`, `access_token`, `refresh_token`, `token`.
233
253
 
234
- To opt out of body/DOM masking: `privacy: { level: "allow" }`. Built-in sensitive
235
- headers (`authorization`/`cookie`/`set-cookie` and `token`/`key`/`secret`/`auth`-like
236
- names) are still stripped regardless of level.
254
+ To opt out entirely: `privacy: { level: "allow" }`.
255
+
256
+ ---
257
+
258
+ ## Session replay
259
+
260
+ Replay is recorded with [**rrweb**](https://github.com/rrweb-io/rrweb). The SDK wires rrweb’s
261
+ record-time masking from your [`privacy`](#privacy--data-masking) config:
262
+
263
+ - `privacy.level` / `privacy.maskSelectors` → rrweb `maskAllInputs` + `maskTextSelector`
264
+ - `privacy.replay.maskTextFn` / `maskInputFn` → rrweb `maskTextFn` / `maskInputFn`
265
+ - `options.replay.blockedSelectors` → rrweb `blockSelector` (noise reduction)
266
+
267
+ Recording starts automatically when `replay` is enabled (it is, by default — empty `enabledEvents`).
268
+ You can also control it manually:
269
+
270
+ ```ts
271
+ groundcover.startReplayRecording();
272
+ groundcover.stopReplayRecording();
273
+ ```
274
+
275
+ > rrweb mask options are fixed at `record()` time. Changing privacy config at runtime via
276
+ > `updateConfig` automatically **restarts** the active recording so the new masking applies.
277
+
278
+ ---
279
+
280
+ ## Architecture
281
+
282
+ Every captured event flows through one pipeline from instrumentation to intake. The two public
283
+ hooks — **`beforeSend`** (drop gate) and **`enrichEvent`** (transform) — run in the events pool,
284
+ *after* privacy redaction and internal enrichment, so they always see the final, already-redacted
285
+ event.
286
+
287
+ ```mermaid
288
+ flowchart TD
289
+ L["Listeners (events/listeners)<br/>dom · network · errors · logs<br/>navigation · pageload · performance · replay"]
290
+ R["Privacy redaction<br/>(getPrivacy) — bodies, headers,<br/>query params, logs, errors"]
291
+ EI["Internal enrichment<br/>id · span/trace ids · timestamps · location"]
292
+ BS{"beforeSend(event)<br/>returns false?"}
293
+ DROP["✗ dropped"]
294
+ EN["enrichEvent(event)<br/>mutate / replace"]
295
+ P["events-pool<br/>batch by transport.batchSize / transport.batchTimeout"]
296
+ T["transporter<br/>gzip (transport.compression)"]
297
+ I[("groundcover intake")]
298
+
299
+ L --> R --> EI --> BS
300
+ BS -- "false" --> DROP
301
+ BS -- "keep" --> EN --> P --> T --> I
302
+ ```
237
303
 
238
- ### Advanced
304
+ **Event lifecycle (in order):**
239
305
 
240
- - `privacy.redact(field)` — per-leaf hook for full control over non-replay
241
- redaction (network/log/error). Return a replacement primitive, or `undefined`
242
- to defer to the SDK default.
243
- - `privacy.replay.maskTextFn` / `privacy.replay.maskInputFn` — rrweb
244
- pass-throughs for session-replay text/input masking. A custom `maskInputFn`
245
- takes over input masking in the replay (it is called for every input).
306
+ 1. **Capture** — a listener builds the raw event.
307
+ 2. **Redact** — sensitive payload is masked per the resolved [`privacy`](#privacy--data-masking) config.
308
+ 3. **Enrich (internal)** — the SDK stamps id / span & trace ids / timestamps / `location` (location query params are redacted here too).
309
+ 4. **`beforeSend(event)`** — your hook; return `false` to **drop** the event. Receives the fully-redacted, enriched event.
310
+ 5. **`enrichEvent(event)`** — your hook; mutate or replace the event before it’s buffered.
311
+ 6. **Batch** — buffered in the events pool, flushed at `transport.batchSize` or `transport.batchTimeout`.
312
+ 7. **Send** — gzipped (`transport.compression`) and delivered to intake.
246
313
 
247
- > The legacy `enableMasking` / `maskFields` options are **deprecated** but still
248
- > honored: `enableMasking: true` → `'mask-all'`, explicit `enableMasking: false`
249
- > → `'allow'`, and `maskFields` entries match DOM element attributes. Prefer
250
- > `privacy.level` / `privacy.maskSelectors` / `privacy.sensitiveKeys`.
314
+ Supporting pieces:
251
315
 
252
- Attributes you send **explicitly** via `groundcover.sendCustomEvent(...)` are
253
- **not** auto-redacted — scrub them before sending (or via `enrichEvent`).
316
+ - **`instrumentation-manager`** installs listeners per `enabledEvents`.
317
+ - **`config-manager`** holds the resolved `SDKConfig` and a memoized `getPrivacy()` read by every
318
+ listener; runtime `updateConfig` invalidates the privacy memo.
319
+ - **`session-manager`** owns the session lifecycle — see [below](#session-lifecycle).
320
+ - **`worker`** offloads replay packing + gzip off the main thread.
321
+
322
+ > Note: `beforeSend`/`enrichEvent` do **not** see explicitly-sent `sendCustomEvent` payloads pre-redacted —
323
+ > those are deliberately-provided and are not auto-redacted; scrub them yourself or via `enrichEvent`.
324
+
325
+ ---
254
326
 
255
327
  ## Web Worker offloading
256
328
 
257
- The SDK spawns a dedicated Web Worker on init and offloads CPU-heavy work — session-replay event packing and outgoing-batch gzip — off the main thread so it doesn't compete with the host page's UI work.
329
+ The SDK spawns a dedicated Web Worker on init and offloads CPU-heavy work — session-replay event
330
+ packing and outgoing-batch gzip — off the main thread so it doesn’t compete with your page’s UI
331
+ work.
258
332
 
259
- The worker is bundled inline (no extra files to host) and spawned from a `blob:` URL. On environments that don't permit blob-sourced workers — strict Content Security Policy without `worker-src 'self' blob:`, sandboxed iframes, SSR — the SDK silently falls back to the existing main-thread implementation. Behavior, wire format, and the public API are identical in both modes.
333
+ The worker is bundled inline (no extra files to host) and spawned from a `blob:` URL. On
334
+ environments that don’t permit blob-sourced workers — strict Content Security Policy without
335
+ `worker-src 'self' blob:`, sandboxed iframes, SSR — the SDK silently falls back to the main-thread
336
+ implementation. Behavior, wire format, and the public API are identical in both modes.
260
337
 
261
- You can pass the values by calling the init function:
338
+ ---
262
339
 
263
- ```typescript
264
- groundcover.init({
265
- apiKey: "your-api-key",
266
- cluster: "your-cluster",
267
- environment: "production",
268
- dsn: "your-dsn",
269
- appId: "your-app-id",
270
- sessionId: "optional-session-id", // For micro-frontend synchronization
271
- options: {
272
- batchSize: 50,
273
- sessionSampleRate: 0.5, // 50% sessions sampled
274
- eventSampleRate: 0.5,
275
- },
340
+ ## Session lifecycle
341
+
342
+ `sessionMaxDuration` sets a **target** maximum wall-clock session length (default 4 hours; must be
343
+ between 1 minute and 8 hours).
344
+
345
+ It is enforced **lazily, on activity — not by a background timer**, so it is **not a hard upper
346
+ bound**. Once the cap has elapsed, the *next* user/business event (click, navigation, log, custom,
347
+ network, exception, …) flushes pending events under the current session id, mints a fresh id, and
348
+ resumes replay recording if it had been active.
349
+
350
+ Because rotation is activity-gated, a session that goes idle keeps its id past the cap until the
351
+ next qualifying event:
352
+
353
+ - A dormant or backgrounded tab that produces no further events stays on the same session id — by
354
+ design, so the SDK doesn’t mint “phantom” sessions for tabs nobody is using.
355
+ - **Session-replay batches don’t count as activity** (rrweb emits a snapshot heartbeat every ~30s
356
+ regardless of interaction), so a tab that’s only recording replay won’t rotate on that alone. It’s
357
+ ultimately bounded by the 30-minute replay-inactivity stop, not by this cap.
358
+
359
+ Sessions are also bounded by a 30-minute inactivity gap, enforced the same lazy way. The flush is
360
+ best-effort and the rotation always proceeds regardless of delivery success. Invalid values fall
361
+ back to the default with a `console.warn`.
362
+
363
+ ### Micro-frontend session synchronization
364
+
365
+ Pass a shared `sessionId` so multiple frontends report under one session:
366
+
367
+ ```ts
368
+ const sharedSessionId = "session-12345";
369
+ groundcover.init({ /* …app… */ apiKey, dsn, cluster, appId: "shell", sessionId: sharedSessionId });
370
+ groundcover.init({ /* …mfe… */ apiKey, dsn, cluster, appId: "micro-frontend", sessionId: sharedSessionId });
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Performance considerations
376
+
377
+ - **Batching & compression** — events are buffered (`transport.batchSize` / `transport.batchTimeout`) and
378
+ gzipped (`transport.compression`) to minimize request count and payload size.
379
+ - **Web Worker offload** — replay packing and gzip run off the main thread (see above).
380
+ - **Sampling** — cap volume with `sessionSampleRate` / `eventSampleRate`.
381
+ - **Scope what you capture** — drop noisy endpoints with `excludedUrls`, and disable unused
382
+ instrumentations via `enabledEvents`.
383
+ - **Lazy session rotation** — no background timers; rotation work happens only on real activity.
384
+ - **Bundle size** — ships ESM + CJS with built-in types; the published bundle is size-budgeted in
385
+ CI.
386
+
387
+ ---
388
+
389
+ ## API reference
390
+
391
+ All methods are available on the default export and on `window.groundcover`.
392
+
393
+ | Method | Description |
394
+ |---|---|
395
+ | `init(config)` | Initialize the SDK and install instrumentation. |
396
+ | `identifyUser(user)` | Attach user identity to subsequent events. |
397
+ | `sendCustomEvent({ event, attributes })` | Emit a custom business event. |
398
+ | `captureException(error, metadata?)` | Capture a handled error with optional context. |
399
+ | `logger.{log,info,warn,error,debug,trace}(message, attributes?)` | Structured logging (see below). |
400
+ | `updateConfig({ options?, user?, … })` | Update config at runtime (merges nested groups one level deep). |
401
+ | `startNavigation(metadata)` / `endNavigation(metadata)` | Manual navigation spans (when `navigation` isn’t auto-tracked). |
402
+ | `getSessionId()` / `setSessionId(id?)` | Read / override the current session id. |
403
+ | `startReplayRecording()` / `stopReplayRecording()` | Manually control session replay. |
404
+
405
+ ### Structured logs
406
+
407
+ `groundcover.logger` mirrors Sentry’s `logger` pattern — one method per level, second arg is the
408
+ attributes object. Nested objects are flattened to dotted keys:
409
+
410
+ ```ts
411
+ groundcover.logger.warn("Checkout failed", {
412
+ orderId: "ord_42",
413
+ cart: { items: 3, total: 99.99 }, // → cart.items, cart.total
276
414
  });
277
415
  ```
278
416
 
279
- Or via the updateConfig function (options are nested under `options`,
280
- matching the `init` shape):
417
+ The SDK also auto-captures `console.*` calls; when any argument is a plain object, its keys are
418
+ promoted to structured log attributes. Reserved keys (`message`, `level`, `location`) are always
419
+ set by the SDK and can’t be overridden.
281
420
 
282
- ```typescript
283
- groundcover.updateConfig({
284
- options: {
285
- batchSize: 20,
286
- },
421
+ ### `identifyUser`
422
+
423
+ ```ts
424
+ groundcover.identifyUser({
425
+ id: "u_123",
426
+ email: "john@acme.com",
427
+ organization: "acme",
428
+ });
429
+ ```
430
+
431
+ ### `captureException`
432
+
433
+ ```ts
434
+ groundcover.captureException(new Error("Payment failed"), {
435
+ userId: "123",
436
+ feature: "checkout",
287
437
  });
288
438
  ```
439
+
440
+ ---
441
+
442
+ ## Migrating to 1.0.0
443
+
444
+ `1.0.0` restructures `options` by concern (a clean break from `0.x`) and removes the deprecated
445
+ masking flags. Update your config as follows:
446
+
447
+ | `0.x` | `1.0.0` |
448
+ |---|---|
449
+ | `environment` *(duplicated in `options`)* | top-level `environment` only |
450
+ | `userIdentifier` | `user` |
451
+ | `options.sessionReplay.blockedSelectors` | `options.replay.blockedSelectors` |
452
+ | `options.tracePropagationUrls` | `options.tracing.propagationUrls` |
453
+ | `options.tracePropagationHeaders` | `options.tracing.propagationHeaders` |
454
+ | `options.tracePropagationTraceIdHeaderName` | `options.tracing.traceIdHeaderName` |
455
+ | `options.tracePropagationSpanIdHeaderName` | `options.tracing.spanIdHeaderName` |
456
+ | `options.traceOrigin` | `options.tracing.origin` |
457
+ | `options.batchSize` / `options.batchTimeout` | `options.transport.batchSize` / `options.transport.batchTimeout` |
458
+ | `options.enableCompression` | `options.transport.compression` |
459
+ | `options.enableMasking: true` *(removed — ignored)* | set `options.privacy.level: 'mask-all'` |
460
+ | `options.enableMasking: false` *(removed — ignored)* | set `options.privacy.level: 'allow'` |
461
+ | `options.maskFields` *(removed — ignored)* | set `options.privacy.maskSelectors` / `options.privacy.sensitiveKeys` |
462
+
463
+ > ⚠️ The removed masking flags are **ignored, not auto-mapped** — passing `enableMasking` /
464
+ > `maskFields` logs a `console.warn` and has **no effect**. You must set the corresponding `privacy`
465
+ > option yourself (right-hand column). Masking remains **on by default** (`mask-sensitive`); if you
466
+ > previously relied on masking being off, set `privacy: { level: 'allow' }` explicitly.