npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm install @groundcover/browser
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(). 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) 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:

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-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. The SDK wires rrweb’s record-time masking from your privacy 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):

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.

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 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.
  • 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.

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 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:

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 / 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.