@groundcover/browser 0.1.0 → 1.0.1

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