@groundcover/browser
v1.0.6
Published
groundcover Real User Monitoring (RUM) SDK for the browser — performance, errors, logs, distributed tracing and session replay
Readme
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 under the hood.
See the groundcover RUM documentation for the full product guide.
Table of contents
- Quick start
- Configuration reference
- HTTP client integration
- Privacy & data masking
- Session replay
- Architecture
- Web Worker offloading
- Session lifecycle
- Performance considerations
- API reference
- Migrating to 1.0.0
Quick start
npm install @groundcover/browserimport 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(). From here you can enrich it:
// Tie events to a user
groundcover.identifyUser({ id: "u_123", email: "[email protected]", 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.
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). |
| sessionId | string | — | Use a shared session id — see micro-frontends. |
| 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() 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. |
| beforeSend | (event) => boolean | — | Drop gate — return false to discard an event. Runs after redaction/enrichment; see event lifecycle. |
| enrichEvent | (event) => event | — | Mutate/replace an event before batching. Runs right after beforeSend; see event lifecycle. |
options.privacy
Drives both replay and non-replay masking. On by default. See 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. |
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.
| 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) withoptions.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:
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'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:
// 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). |
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-keyalso match). - Request/response headers — always stripped regardless of
level:authorization,cookie,set-cookie, plus any header name containingtoken,key,secret,passwd,password,auth,bearer, orcredential. - 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. The SDK wires rrweb’s
record-time masking from your privacy config:
privacy.level/privacy.maskSelectors→ rrwebmaskAllInputs+maskTextSelectorprivacy.replay.maskTextFn/maskInputFn→ rrwebmaskTextFn/maskInputFnoptions.replay.blockedSelectors→ rrwebblockSelector(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):
groundcover.startReplayRecording();
groundcover.stopReplayRecording();rrweb mask options are fixed at
record()time. Changing privacy config at runtime viaupdateConfigautomatically 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.
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 --> IEvent lifecycle (in order):
- Capture — a listener builds the raw event.
- Redact — sensitive payload is masked per the resolved
privacyconfig. - Enrich (internal) — the SDK stamps id / span & trace ids / timestamps /
location(location query params are redacted here too). beforeSend(event)— your hook; returnfalseto drop the event. Receives the fully-redacted, enriched event.enrichEvent(event)— your hook; mutate or replace the event before it’s buffered.- Batch — buffered in the events pool, flushed at
transport.batchSizeortransport.batchTimeout. - Send — gzipped (
transport.compression) and delivered to intake.
Supporting pieces:
instrumentation-managerinstalls listeners perenabledEvents.config-managerholds the resolvedSDKConfigand a memoizedgetPrivacy()read by every listener; runtimeupdateConfiginvalidates the privacy memo.session-managerowns the session lifecycle — see below.workeroffloads replay packing + gzip off the main thread.
Note:
beforeSend/enrichEventdo not see explicitly-sentsendCustomEventpayloads pre-redacted — those are deliberately-provided and are not auto-redacted; scrub them yourself or viaenrichEvent.
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.
Micro-frontend session synchronization
Pass a shared sessionId so multiple frontends report under one session:
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 viaenabledEvents. - 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:
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
groundcover.identifyUser({
id: "u_123",
email: "[email protected]",
organization: "acme",
});captureException
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/maskFieldslogs aconsole.warnand has no effect. You must set the correspondingprivacyoption yourself (right-hand column). Masking remains on by default (mask-sensitive); if you previously relied on masking being off, setprivacy: { level: 'allow' }explicitly.
