<p align="center">
  <img src="https://cdn.jsdelivr.net/npm/@groundcover/browser/assets/logo.png" alt="groundcover" width="240" />
</p>

<h1 align="center">RUM — Browser SDK</h1>

<p align="center">
  <a href="https://www.npmjs.com/package/@groundcover/browser"><img src="https://img.shields.io/npm/v/@groundcover/browser.svg" alt="npm version" /></a>
  <a href="https://bundlejs.com/?q=@groundcover/browser"><img src="https://img.shields.io/bundlejs/size/@groundcover/browser" alt="minzipped size" /></a>
</p>

groundcover’s Real User Monitoring (RUM) SDK captures front-end **performance**, **user
interactions**, **errors**, **logs**, **distributed traces**, and **session replay** from your web
application — with privacy masking **on by default**.

> Session replay is powered by [**rrweb**](https://github.com/rrweb-io/rrweb) under the hood.

See the [groundcover RUM documentation](https://docs.groundcover.com/capabilities/real-user-monitoring-rum/)
for the full product guide.

---

## Table of contents

- [Quick start](#quick-start)
- [Configuration reference](#configuration-reference)
- [HTTP client integration](#http-client-integration)
- [Privacy & data masking](#privacy--data-masking)
- [Session replay](#session-replay)
- [Architecture](#architecture)
- [Web Worker offloading](#web-worker-offloading)
- [Session lifecycle](#session-lifecycle)
- [Performance considerations](#performance-considerations)
- [API reference](#api-reference)
- [Migrating to 1.0.0](#migrating-to-100)

---

## Quick start

```bash
npm install @groundcover/browser
```

```ts
import groundcover from "@groundcover/browser";

groundcover.init({
  apiKey: "your-api-key",
  dsn: "your-dsn",
  cluster: "your-cluster",
  appId: "your-app-id",
  environment: "production",
});
```

That single call installs every instrumentation (page loads, DOM interactions, network requests,
errors, console logs, navigation, and performance) and starts sending data. Session replay is the
one exception — it must be started explicitly with
[`startReplayRecording()`](#session-replay). From here you can enrich it:

```ts
// Tie events to a user
groundcover.identifyUser({ id: "u_123", email: "john@acme.com", organization: "acme" });

// Capture a handled error
groundcover.captureException(new Error("Checkout failed"), { feature: "checkout" });

// Emit a structured log
groundcover.logger.warn("Payment retry", { provider: "stripe", attempt: 2 });

// Emit a custom business event
groundcover.sendCustomEvent({ event: "plan_upgraded", attributes: { plan: "pro" } });
```

---

## Configuration reference

`init()` takes connection/identity fields at the top level and all behavioral knobs under
`options`, grouped by concern.

```ts
groundcover.init({
  // ── Connection & identity ──
  apiKey, dsn, cluster, appId,
  environment, namespace, releaseId,
  user, sessionId,

  options: {
    debug,
    sessionSampleRate, eventSampleRate,
    enabledEvents, excludedUrls,
    sessionMaxDuration,
    beforeSend, enrichEvent,
    privacy:   { /* … */ },
    tracing:   { /* … */ },
    transport: { /* … */ },
    replay:    { /* … */ },
  },
});
```

### Top-level (connection & identity)

| Field | Type | Required | Description |
|---|---|---|---|
| `apiKey` | `string` | ✅ | Your groundcover RUM API key. |
| `dsn` | `string` | ✅ | RUM intake endpoint. A bare host is upgraded to `https://`. |
| `cluster` | `string` | ✅ | Target groundcover cluster. |
| `appId` | `string` | ✅ | Application identifier; reported as `service.name`. |
| `environment` | `string` | — | Deployment environment (e.g. `production`, `staging`). |
| `namespace` | `string` | — | Logical grouping/namespace for the app. |
| `releaseId` | `string` | — | Release/version identifier (e.g. a semver or commit hash). |
| `user` | `Partial<UserIdentifiers>` | — | Identify the user at init (same shape as [`identifyUser`](#identifyuser)). |
| `sessionId` | `string` | — | Use a shared session id — see [micro-frontends](#micro-frontend-session-synchronization). |
| `options` | `Partial<SDKOptions>` | — | Behavioral configuration, below. |

### `options` — top-level knobs

| Option | Type | Default | Description |
|---|---|---|---|
| `debug` | `boolean` | `false` | Verbose SDK console logging. |
| `sessionSampleRate` | `number` | `1` | Fraction of sessions captured (0–1). |
| `eventSampleRate` | `number` | `1` | Fraction of events captured (0–1). |
| `enabledEvents` | `Array<…>` | `[]` | Instrumentations to enable. **Empty = all.** Members: `dom`, `network`, `exceptions`, `logs`, `pageload`, `navigation`, `performance`, `replay`. Note: `replay` still requires an explicit [`startReplayRecording()`](#session-replay) call — it never auto-starts. |
| `excludedUrls` | `Array<string \| RegExp>` | — | Network requests matching these are not captured. |
| `sessionMaxDuration` | `number` | `4h` | Target max session length in ms (1 min – 8 h). See [session lifecycle](#session-lifecycle). |
| `beforeSend` | `(event) => boolean` | — | Drop gate — return `false` to discard an event. Runs after redaction/enrichment; see [event lifecycle](#architecture). |
| `enrichEvent` | `(event) => event` | — | Mutate/replace an event before batching. Runs right after `beforeSend`; see [event lifecycle](#architecture). |

### `options.privacy`

Drives both replay and non-replay masking. **On by default.** See
[Privacy & data masking](#privacy--data-masking) for the full table.

### `options.tracing`

Distributed-tracing header propagation for outgoing requests.

| Option | Type | Default | Description |
|---|---|---|---|
| `propagationUrls` | `string[]` | `[]` | Request URLs (prefix/`*`-glob match) that receive injected tracing headers. |
| `propagationHeaders` | `string[]` | `[]` | Header names to read/propagate onto traced requests. |
| `traceIdHeaderName` | `string` | `""` | Header carrying the trace id. |
| `spanIdHeaderName` | `string` | `""` | Header carrying the span id. |
| `origin` | `{ name: string; value: string }` | `{ name: "", value: "" }` | Origin tag stamped on injected trace headers. |

```ts
options: {
  tracing: {
    propagationUrls: ["https://api.acme.com/*"],
    traceIdHeaderName: "x-groundcover-trace-id",
    spanIdHeaderName: "x-groundcover-span-id",
  },
}
```

### `options.transport`

Batching and delivery of outgoing events.

| Option | Type | Default | Description |
|---|---|---|---|
| `batchSize` | `number` | `10` | Max events buffered before a batch flushes. |
| `batchTimeout` | `number` | `10000` | Max ms a batch waits before flushing regardless of size. |
| `compression` | `boolean` | `true` | Gzip-compress batches (offloaded to the Web Worker when available). |

### `options.replay`

Session-replay recording controls. **This is noise reduction, not privacy** — to mask sensitive
content use [`privacy`](#privacy--data-masking).

| Option | Type | Default | Description |
|---|---|---|---|
| `blockedSelectors` | `string[]` | — | CSS selectors whose elements (and subtrees) are excluded from recording. Useful for noisy extension-injected DOM (e.g. Grammarly). |

> ⚠️ Don’t confuse `options.replay.blockedSelectors` (recording noise reduction) with
> `options.privacy.replay.*` (rrweb masking callbacks). They live at different levels and serve
> different purposes.

### Updating config at runtime

`updateConfig` mirrors the `init` shape; nested groups merge one level deep (and `tracing.origin` /
`privacy.replay` one level deeper), so a partial update preserves the other keys in the group:

```ts
groundcover.updateConfig({
  options: {
    transport: { batchSize: 20 }, // batchTimeout/compression are preserved
    privacy: { level: "mask-all" },
  },
});
```

For `user`, an omitted key leaves the current identity untouched, passing an object merges into it,
and passing `null` clears it (e.g. on logout).

---

## HTTP client integration

The SDK instruments network requests by wrapping the global `fetch` and `XMLHttpRequest` on
`init()`. Most clients are captured automatically, but *how* a client resolves `fetch` decides
whether it gets instrumented.

**Initialize `groundcover.init()` before constructing fetch-based clients.** `init()` replaces
`globalThis.fetch` with an instrumented wrapper. Libraries that capture a reference to
`globalThis.fetch` at construction time — e.g. [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/)'s
`createClient()`, whose `fetch` option defaults to `globalThis.fetch` — freeze the *native* fetch if
they are built first, so their requests bypass RUM. Two ways to avoid this:

```ts
// 1) Init the SDK before you build the client
groundcover.init({ /* ... */ });
const client = createClient({ baseUrl: "/" });

// 2) Or late-bind fetch so the client resolves the wrapped fetch per request
// (forward both args so method/headers/body/signal from an `init` are preserved)
const client = createClient({
  baseUrl: "/",
  fetch: (input, init) => globalThis.fetch(input, init),
});
```

Option 2 is the robust choice when import order is hard to guarantee: bundlers evaluate a module's
static imports before its body, so a client built at module scope is often constructed *before* your
`init()` call runs.

**XHR-based clients (e.g. axios) are always instrumented** regardless of init order — the SDK patches
`XMLHttpRequest.prototype`, so every request created after `init()` is covered.

**`Request`-object calls are captured.** Clients that call
`fetch(new Request(url, { method, body }))` (openapi-fetch does) carry the method and body on the
`Request` rather than in an `init` argument; the SDK reads the method from either (since 1.0.4) and
the request *body* from either (since 1.0.5). Method, URL, headers, status, request body, and
response body are recorded. The `Request` body is read from a clone taken before the request is
sent, so the app's copy is never consumed.

Request and response bodies are captured the same way: up to a size cap, with redaction applied
*before* truncation so sensitive keys are always masked. A body larger than the cap is recorded as
`[request body too large]` / `[response body too large]` rather than a truncated fragment — a partial
body can't be parsed for structured redaction, so it is never emitted. Non-text bodies are
placeholdered by content-type (`[binary data]`, `[image data]`, `[pdf data]`, `[form data]`, …), and
a slow or never-closing body read resolves to `[request body unavailable]` /
`[response body unavailable]` so it can never defer the event.

---

## Privacy & data masking

Masking is **on by default** (`privacy.level: 'mask-sensitive'`). A single `level` is the master
switch; finer toggles and hooks refine it.

| `level` | Replay inputs | Replay text | DOM events | Network / logs / errors |
|---|---|---|---|---|
| `mask-sensitive` **(default)** | sensitive masked | `[data-private]` + `maskSelectors` masked | sensitive masked | redacted |
| `mask-all` | masked | masked (`*`) | all masked | redacted |
| `allow` | — | — | — | off (auth headers are still stripped) |

Under `mask-sensitive`, an input/element is masked when it is a `type="password"`, sits under a
`[data-private]` ancestor or a `maskSelectors` match, or has an `id`/`name`/`class`/`aria-label`/
`placeholder` matching a built-in sensitive-key pattern or your `sensitiveKeys`. Non-sensitive inputs
and static page text stay visible; use `[data-private]` / `maskSelectors` to mask static content.

| Option | Type | Default | Description |
|---|---|---|---|
| `level` | `'mask-sensitive' \| 'mask-all' \| 'allow'` | `'mask-sensitive'` | Master masking switch. |
| `maskSelectors` | `string[]` | — | CSS selectors whose text/inputs are always masked (replay + DOM), unless `level: 'allow'`. |
| `maskNetworkBodies` | `boolean` | `true` | Redact network request/response bodies. |
| `maskNetworkQueryParams` | `boolean` | `true` | Redact URL query params. |
| `maskLogs` | `boolean` | `true` | Redact console/log messages and attributes. |
| `maskErrors` | `boolean` | `true` | Redact error messages, metadata and stack-frame URLs. |
| `sensitiveKeys` | `string[]` | — | Extra case-insensitive key substrings treated as sensitive, merged with built-ins. |
| `redact` | `(field) => value \| undefined` | — | Per-leaf custom redactor for non-replay events. Return `undefined` to defer to the SDK default. |
| `replay.maskTextFn` | `(text, el) => string` | — | rrweb `maskTextFn` pass-through (replay only). |
| `replay.maskInputFn` | `(text, el) => string` | — | rrweb `maskInputFn` pass-through (replay only). |

```ts
options: {
  privacy: {
    level: "mask-sensitive",
    maskSelectors: [".pii", "#ssn"],
    sensitiveKeys: ["account_no"],
  },
}
```

#### Built-in sensitive patterns

These are always treated as sensitive (case-insensitive); your `sensitiveKeys` are merged in on top.

- **Key substrings** — matched as a substring of a body/query key or a DOM element attribute
  (`id`/`name`/`class`/`aria-label`/`placeholder`):
  `token`, `secret`, `passwd`, `password`, `api_key`, `access_key`, `write_key`, `auth`, `bearer`,
  `credential`, `cvv`, `ssn`, `credit_card`, `card_number` (the `_` in the last five is optional —
  `apikey` / `api-key` also match).
- **Request/response headers** — always stripped regardless of `level`: `authorization`, `cookie`,
  `set-cookie`, plus any header name containing `token`, `key`, `secret`, `passwd`, `password`,
  `auth`, `bearer`, or `credential`.
- **Query / form param names** — matched as a whole key (not inside JSON bodies), for OAuth-style
  callbacks: `code`, `state`, `session_state`, `id_token`, `access_token`, `refresh_token`, `token`.

To opt out entirely: `privacy: { level: "allow" }`.

---

## Session replay

Replay is recorded with [**rrweb**](https://github.com/rrweb-io/rrweb). The SDK wires rrweb’s
record-time masking from your [`privacy`](#privacy--data-masking) config:

- `privacy.level` / `privacy.maskSelectors` → rrweb `maskAllInputs` + `maskTextSelector`
- `privacy.replay.maskTextFn` / `maskInputFn` → rrweb `maskTextFn` / `maskInputFn`
- `options.replay.blockedSelectors` → rrweb `blockSelector` (noise reduction)

Replay recording does **not** start automatically — even when `replay` is in `enabledEvents`. You
must start it explicitly (e.g. after obtaining user consent):

```ts
groundcover.startReplayRecording();
groundcover.stopReplayRecording();
```

> rrweb mask options are fixed at `record()` time. Changing privacy config at runtime via
> `updateConfig` automatically **restarts** the active recording so the new masking applies.

---

## Architecture

Every captured event flows through one pipeline from instrumentation to intake. The two public
hooks — **`beforeSend`** (drop gate) and **`enrichEvent`** (transform) — run in the events pool,
*after* privacy redaction and internal enrichment, so they always see the final, already-redacted
event.

```mermaid
flowchart TD
    L["Listeners (events/listeners)<br/>dom · network · errors · logs<br/>navigation · pageload · performance · replay"]
    R["Privacy redaction<br/>(getPrivacy) — bodies, headers,<br/>query params, logs, errors"]
    EI["Internal enrichment<br/>id · span/trace ids · timestamps · location"]
    BS{"beforeSend(event)<br/>returns false?"}
    DROP["✗ dropped"]
    EN["enrichEvent(event)<br/>mutate / replace"]
    P["events-pool<br/>batch by transport.batchSize / transport.batchTimeout"]
    T["transporter<br/>gzip (transport.compression)"]
    I[("groundcover intake")]

    L --> R --> EI --> BS
    BS -- "false" --> DROP
    BS -- "keep" --> EN --> P --> T --> I
```

**Event lifecycle (in order):**

1. **Capture** — a listener builds the raw event.
2. **Redact** — sensitive payload is masked per the resolved [`privacy`](#privacy--data-masking) config.
3. **Enrich (internal)** — the SDK stamps id / span & trace ids / timestamps / `location` (location query params are redacted here too).
4. **`beforeSend(event)`** — your hook; return `false` to **drop** the event. Receives the fully-redacted, enriched event.
5. **`enrichEvent(event)`** — your hook; mutate or replace the event before it’s buffered.
6. **Batch** — buffered in the events pool, flushed at `transport.batchSize` or `transport.batchTimeout`.
7. **Send** — gzipped (`transport.compression`) and delivered to intake.

Supporting pieces:

- **`instrumentation-manager`** installs listeners per `enabledEvents`.
- **`config-manager`** holds the resolved `SDKConfig` and a memoized `getPrivacy()` read by every
  listener; runtime `updateConfig` invalidates the privacy memo.
- **`session-manager`** owns the session lifecycle — see [below](#session-lifecycle).
- **`worker`** offloads replay packing + gzip off the main thread.

> Note: `beforeSend`/`enrichEvent` do **not** see explicitly-sent `sendCustomEvent` payloads pre-redacted —
> those are deliberately-provided and are not auto-redacted; scrub them yourself or via `enrichEvent`.

---

## Web Worker offloading

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 your page’s UI
work.

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 main-thread
implementation. Behavior, wire format, and the public API are identical in both modes.

---

## Session lifecycle

`sessionMaxDuration` sets a **target** maximum wall-clock session length (default 4 hours; must be
between 1 minute and 8 hours).

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, mints a fresh id, and
resumes replay recording if it had been active.

Because rotation is activity-gated, a session that goes idle keeps its id past the cap until the
next qualifying event:

- 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.
- **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. It’s
  ultimately bounded by the 30-minute replay-inactivity stop, not by this cap.

Sessions are also bounded by a 30-minute inactivity gap, enforced the same lazy way. The flush is
best-effort and the rotation always proceeds regardless of delivery success. Invalid values fall
back to the default with a `console.warn`.

**When a rotated session starts.** A new session's `session_start_time` is taken from the earliest
event that belongs to it — normally the very event that triggered the rotation — rather than from the
instant the rotation ran. The two differ by the few milliseconds the rotation itself takes, and using
the later value would report a session as starting *after* an event it carries, which reads downstream
as a negative duration. Candidates are only accepted if they are no older than one inactivity window
and not in the future, so a `beforeSend`/`enrichEvent` hook returning a wildly skewed timestamp cannot
move a session's start (the same value also drives the cap and inactivity clocks).

This assumes a fully configured SDK, which means `cluster` supplied at `init` — it is required, and
every other guarantee here is stated for that case.

### Micro-frontend session synchronization

Pass a shared `sessionId` so multiple frontends report under one session:

```ts
const sharedSessionId = "session-12345";
groundcover.init({ /* …app… */ apiKey, dsn, cluster, appId: "shell", sessionId: sharedSessionId });
groundcover.init({ /* …mfe… */ apiKey, dsn, cluster, appId: "micro-frontend", sessionId: sharedSessionId });
```

---

## Performance considerations

- **Batching & compression** — events are buffered (`transport.batchSize` / `transport.batchTimeout`) and
  gzipped (`transport.compression`) to minimize request count and payload size.
- **Web Worker offload** — replay packing and gzip run off the main thread (see above).
- **Sampling** — cap volume with `sessionSampleRate` / `eventSampleRate`.
- **Scope what you capture** — drop noisy endpoints with `excludedUrls`, and disable unused
  instrumentations via `enabledEvents`.
- **Lazy session rotation** — no background timers; rotation work happens only on real activity.
- **Bundle size** — ships ESM + CJS with built-in types; the published bundle is size-budgeted in
  CI.

---

## API reference

All methods are available on the default export and on `window.groundcover`.

| Method | Description |
|---|---|
| `init(config)` | Initialize the SDK and install instrumentation. |
| `identifyUser(user)` | Attach user identity to subsequent events. |
| `sendCustomEvent({ event, attributes })` | Emit a custom business event. |
| `captureException(error, metadata?)` | Capture a handled error with optional context. |
| `logger.{log,info,warn,error,debug,trace}(message, attributes?)` | Structured logging (see below). |
| `updateConfig({ options?, user?, … })` | Update config at runtime (merges nested groups one level deep). |
| `startNavigation(metadata)` / `endNavigation(metadata)` | Manual navigation spans (when `navigation` isn’t auto-tracked). |
| `getSessionId()` / `setSessionId(id?)` | Read / override the current session id. |
| `startReplayRecording()` / `stopReplayRecording()` | Manually control session replay. |

### Structured logs

`groundcover.logger` mirrors Sentry’s `logger` pattern — one method per level, second arg is the
attributes object. Nested objects are flattened to dotted keys:

```ts
groundcover.logger.warn("Checkout failed", {
  orderId: "ord_42",
  cart: { items: 3, total: 99.99 }, // → cart.items, cart.total
});
```

The SDK also auto-captures `console.*` calls; when any argument is a plain object, its keys are
promoted to structured log attributes. Reserved keys (`message`, `level`, `location`) are always
set by the SDK and can’t be overridden.

### `identifyUser`

```ts
groundcover.identifyUser({
  id: "u_123",
  email: "john@acme.com",
  organization: "acme",
});
```

### `captureException`

```ts
groundcover.captureException(new Error("Payment failed"), {
  userId: "123",
  feature: "checkout",
});
```

---

## Migrating to 1.0.0

`1.0.0` restructures `options` by concern (a clean break from `0.x`) and removes the deprecated
masking flags. Update your config as follows:

| `0.x` | `1.0.0` |
|---|---|
| `environment` *(duplicated in `options`)* | top-level `environment` only |
| `userIdentifier` | `user` |
| `options.sessionReplay.blockedSelectors` | `options.replay.blockedSelectors` |
| `options.tracePropagationUrls` | `options.tracing.propagationUrls` |
| `options.tracePropagationHeaders` | `options.tracing.propagationHeaders` |
| `options.tracePropagationTraceIdHeaderName` | `options.tracing.traceIdHeaderName` |
| `options.tracePropagationSpanIdHeaderName` | `options.tracing.spanIdHeaderName` |
| `options.traceOrigin` | `options.tracing.origin` |
| `options.batchSize` / `options.batchTimeout` | `options.transport.batchSize` / `options.transport.batchTimeout` |
| `options.enableCompression` | `options.transport.compression` |
| `options.enableMasking: true` *(removed — ignored)* | set `options.privacy.level: 'mask-all'` |
| `options.enableMasking: false` *(removed — ignored)* | set `options.privacy.level: 'allow'` |
| `options.maskFields` *(removed — ignored)* | set `options.privacy.maskSelectors` / `options.privacy.sensitiveKeys` |

> ⚠️ The removed masking flags are **ignored, not auto-mapped** — passing `enableMasking` /
> `maskFields` logs a `console.warn` and has **no effect**. You must set the corresponding `privacy`
> option yourself (right-hand column). Masking remains **on by default** (`mask-sensitive`); if you
> previously relied on masking being off, set `privacy: { level: 'allow' }` explicitly.
