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

epok-rum

v0.1.7

Published

Epok web RUM SDK — Core Web Vitals, distributed-trace propagation, error capture, and session correlation in one initRum() call.

Readme

epok-rum

The Epok web RUM SDK. One call stands up real-user monitoring that shares the backend's trace id — so a user's browser error resolves to the exact backend span and log, and "everything for this user's session across every signal" is one filter.

Sends to the Epok ingest endpoint: spans → POST {endpoint}/v1/traces, vitals → POST {endpoint}/v1/metrics. A page-hide sendBeacon flush keeps the session tail.

Install

This is the canonical install snippet (docs and the landing page link here):

npm install epok-rum@^0.1.7 web-vitals   # web-vitals is optional (enables Core Web Vitals)
import { initRum, createConsentController } from "epok-rum/attribution";
import * as webVitals from "web-vitals/attribution"; // optional

const consent = createConsentController(); // denied until the user grants consent
const rum = initRum({
  consent,
  serviceName: "storefront-web",
  endpoint: "https://ingest.getepok.dev",
  apiKey: "epk_…",                              // your ingest key
  environment: "production",
  release: import.meta.env.GIT_SHA,             // → service.version (symbolication + release facets)
  tracePropagationTargets: ["https://api.getepok.dev"], // same-origin is always propagated
  webVitals,                                    // omit to skip Core Web Vitals
});

// Connect your consent UI to consent.setConsent("granted" | "denied").
// After consent and sign-in, tie telemetry to the user:
rum.setUser({ id: user.id, name: user.name, email: user.email, plan: user.plan });
// App-wide facets on every span + vital from here on:
rum.setGlobalContext("tenant", user.tenantId);

Recommended attribution (since 0.1.4): the epok-rum/attribution entry accepts the v4 or v5 attribution binding. The quick start above uses these attribution imports:

import { initRum, createConsentController } from "epok-rum/attribution";
import * as webVitals from "web-vitals/attribution";

For each poor LCP, INP or CLS report it sends one separate RUM action span, through the existing consent, session sampling, URL scrubbing, beforeSend and delivery pipeline. Plain web-vitals works too, with no extra span.

  • LCP: element selector, scrubbed resource URL, TTFB, resource load delay/duration, and element render delay.
  • INP: interaction target/type, input delay, processing/presentation duration, and load state.
  • CLS: largest shift target/value/time and load state.

Selectors use tag/id/up to two classes, at most 120 characters. Attribution also drops id/class tokens with eight or more digits, secret prefixes or long random-looking values (including SSN/card patterns). Frustration capture retains its original selector policy without these additional token checks. No text content is collected by attribution. Avoid personal data in DOM ids/classes. URLs use the shared scrubUrl policy (including your allowedQueryParams), capped at 256 characters. Arbitrary custom target strings and unsupported selector syntax are omitted. No DOM nodes, event entries or script contents are exported.

action web_vital_attribution spans carry rum.vital.* fields, the same route, resource session id and view id as the vital. Each has its own trace id; existing vital metrics have no trace id, so there is no invented backend trace association. A dropped/throwing vital hook also suppresses attribution; the span itself then passes through the span hook for further redaction or dropping. Detector evidence shows up to five target/resource pairs for a regressed route/vital, best-effort. RUM Top causes shows counts and average phases among the latest 200 poor observations in the selected window (up to 12 groups); it is absent without data. Selectors and resources are never added to rum_web_vital gauge attributes.

Core-only users can keep importing epok-rum; its bytes and metric format are unchanged. Attribution has separate optional ESM and IIFE artifacts. For self-hosted attribution, copy both epok-rum-attribution.esm.js and epok-rum.esm.js to the same directory and import the attribution file. For script tags, load epok-rum.iife.min.js first, then epok-rum-attribution.iife.min.js; initialize through EpokRUMAttribution.initRum (pass the attribution web-vitals binding). Use the matching dist/integrity.json SHA-384 values for self-hosted scripts. Initialize through attribution first: if core was initialized first, attribution returns the existing handle and warns once; it cannot retrofit capture.

For self-hosting, copy the built dist/epok-rum.esm.js to your own static assets and import it from your application's module. Replay is separate: dist/epok-rum-replay.esm.js includes the locked stable rrweb recorder. The build also emits IIFE variants and integrity.json (SHA-384).

Consent defaults to denied for core and replay. Pass the same controller to both, or use the page default via rum.setConsent() (one Symbol.for page default shared across separately loaded bundles, denied in every version). No session, instrumentation, recording or upload starts before a grant.

  • Explicit opt-in to capture while undecided (since 0.1.7): createConsentController("unknown") captures until an explicit denial, for apps whose legal basis does not need prior consent. Never the default. createConsentController("unknown", { required: true }) blocks until a grant (same as denied, but documents a pending decision). setConsent() accepts only a grant; any other value, including "unknown", is a denial.
  • Late grant (since 0.1.7): when capture was blocked at init, capture starts at the grant; nothing before it is back-filled. Core exports the resource attribute rum.consent.granted_late="true", replay chunks carry consent_granted_late: true, and diagnostics.consent.late_grants increments. (The replay field is not stored server-side yet.)
  • Withdrawal (setConsent("denied"), or replay discard()): stops at once without flushing and erases queued spans/vitals, the core IndexedDB retry queue, replay buffers, checkpoints, in-flight chunks and the localStorage dead letter. Re-grant starts fresh; reapply identity/context only after the new grant. Already transmitted data cannot be recalled. Erased items are counted (diagnostics.delivery.dropped_error, replay diagnostics.droppedEvents), and consent transitions in diagnostics.consent (granted/denied). The core delivery counters belong to the current runtime, so after a re-grant they restart from zero.
  • Ordinary shutdown() / replay stop() still flush while granted. Replay stop({ flush: false }) only skips that final flush; it is not withdrawal and erases nothing (a persisted dead letter survives for the next page).

What gets captured

| signal | shape | notes | |---|---|---| | Document load | document_load <route> INTERNAL span, measured | loadEventEnd - max(startTime, activationStart) from PerformanceNavigationTiming; rum.duration_type: page_load, rum.load_end: load\|domcontentloaded, rum.prerendered on an activated prerender; carries the view.id it was initialized with; one per navigation however often initRum is called | | SPA views | one view span per route with dwell duration | pushState/replaceState/popstate/hashchange; view.id stamped on all child telemetry | | fetch + XHR | CLIENT span per request (method/url/status/duration) | W3C traceparent + baggage (session.id, user.id) injected to allow-listed origins — browser trace_id == backend trace_id | | Errors | error span (exception.type/message/stacktrace, error:true) | uncaught errors, unhandled rejections, optional console.error, and addError() — all through one path; stacks capped at 8KB, symbolicated server-side via source maps | | Core Web Vitals | OTLP metric rum_web_vital (LCP/INP/CLS + FCP/TTFB) per normalized route | what the rum_web_vitals_regression detector scores | | Resources | ONE aggregated resources span per view flush | count, total_bytes (when exposed), slowest url (scrubbed) + ms, per-initiatorType counts — never per-resource spans | | Long tasks | longtask span per main-thread block ≥50ms | duration + view.id, capped 20 per view | | Custom actions | action <name> span via addAction() | attrs sanitized: primitives only, capped, URL values scrubbed | | Frustration | rage-click / dead-click spans | default on | | Session | rotating id in localStorage (cross-tab), in baggage end-to-end | 30 min inactivity / 4 h hard-cap rotation |

Page-load semantics (since 0.1.1; prerender handling since 0.1.2)

The document_load span is a measurement, not a marker. Before 0.1.1 its start and end were both read from the clock one statement apart inside initRum(), so it was ~0 ms wide — and because ingest writes a duration_ms for any span whose end is not before its start, anything reading "browser span duration" was mixing that zero in with real request latency.

It is now read from PerformanceNavigationTiming and emitted once per navigation, when the timing is final:

  • start = max(entry.startTime, entry.activationStart) (anchored on performance.timeOrigin)
  • end = entry.loadEventEnd → attribute rum.load_end: "load"
  • if the page is hidden or closed before the load event completes, end = entry.domContentLoadedEventEnd → rum.load_end: "domcontentloaded"
  • a real measurement carries rum.duration_type: "page_load" and rum.load_timing: "navigation"

Prerender (0.1.2)

Chrome's Speculation Rules load a page before the user asks for it, and every Navigation-Timing milestone stays on the prerender's own clock. A page prerendered 58 s before the click and loaded 1.2 s after activation reports loadEventEnd == 59_200 — a 59-second "page load" nobody waited through. Two consequences, both handled:

  • durations are measured from activationStart (the same correction web-vitals applies), clamped at 0, and the span is stamped rum.prerendered: "true". A page that finished loading before activation reports ~0: the user waited ~0.
  • initRum() does nothing at all while document.prerendering is true — no session, no exporter, no spans. It initializes on prerenderingchange, replaying any handle calls made meanwhile. A prerender that is never activated emits nothing, and the handle's sessionId is "" until activation.

Exactly one document_load span is emitted per navigation, keyed on the PerformanceNavigationTiming entry — a second initRum() on the same page (a StrictMode double-effect, an async snippet racing a bundled import) adds no duplicate observation to the detector's p95.

When the host exposes no navigation entry at all (non-browser runtime, very old browser) or nothing has landed by page-hide, the SDK emits the zero-duration marker it always emitted, stamped rum.load_timing: "unavailable" and without rum.duration_type — a fabricated wall-clock duration would be worse than none, because it would be graded. SPA route changes are not page loads: they stay view spans with rum.duration_type: "dwell".

Every export carries the RUM resource marker (ingest stamps rum:true) plus the environment resource attrs browser.name, browser.version, os.name, os.version, device.type (mobile/tablet/desktop) — and service.version when release is set.

Config

| field | default | meaning | |---|---|---| | serviceName | — (required) | logical frontend service name | | endpoint | — (required) | Epok ingest base URL | | consent | denied | shared core/replay controller from createConsentController(); "unknown" is an explicit opt-in to capture while undecided | | apiKey | — | ingest API key (X-API-Key, or ?api_key= on beacons) | | environment | — | deployment.environment resource attr | | release | — | release/version id (e.g. git SHA) → the service.version resource attr on ALL telemetry. Required for source-map symbolication of minified stacks (upload maps for the same release) and for release facets | | sampleRate | 1.0 | trace-propagation sampled-flag fraction (kept sessions) | | sessionSampleRate | 1.0 | fraction of SESSIONS to capture — decided once per session and persisted; a dropped session emits nothing at all | | tracePropagationTargets | [] | extra origins to propagate to (same-origin always). Origin matching is EXACT (new URL(x).origin), never a string prefix — a lookalike host (shop.example.attacker.invalid), a different port, or a userinfo trick ([email protected]) never matches your origin. String entries are origins only. Path scopes require { origin: "https://api.example.com", pathPrefix: "/v1" }, with a segment boundary. Legacy regex and string-path targets fail closed. | | webVitals | — | the web-vitals library (enables LCP/INP/CLS) | | captureConsoleErrors | false | also capture console.error as error spans | | captureFrustration | true | rage clicks + dead clicks as RUM spans | | trackViews | true | SPA view tracking — a view span per route, view.id stamped on child spans/errors/vitals | | trackResources | true | resource timing — ONE aggregated resources span per view flush (volume-guarded; never per-resource spans) | | trackLongTasks | true | longtask span per main-thread block ≥50ms, capped 20/view | | allowedQueryParams | [] | query keys allowed to survive URL scrubbing; every other exported URL query collapses to ?<redacted> | | beforeSend | — | (event) => event \| null \| undefined — inspect/redact every outgoing span or vital. Return null to drop, a mutated copy to redact, or undefined to send the original unmodified. FAIL-CLOSED: a hook that THROWS drops the event (same as null) rather than sending the original — see diagnostics.beforeSendFailures and the Privacy defaults section below | | respectDoNotTrack | false | when true and the browser signals DNT=1, initRum is a no-op (inert handle) |

Returns

interface RumHandle {
  sessionId: string;
  /** id string, or {id, name?, email?, …extras}: enduser.* resource attrs,
   *  extras as user.attr.* (capped 10 keys / 128 chars), user.id baggage. */
  setUser(user: string | { id: string; name?: string; email?: string; [k: string]: unknown }): void;
  /** Stamp key=value on every SUBSEQUENT span + vital (cap 20 keys). Reserved
   *  keys (`service.*`, `session.*`, `user.*`, `enduser.*`, `telemetry.*`,
   *  `browser.*`, `os.*`, `device.*`, `rum.view.*`, and the exact keys
   *  `rum.duration_type` / `rum.load_timing` / `rum.load_end` /
   *  `rum.prerendered`) are silently dropped — no throw, no console log —
   *  since they'd otherwise shadow a resource attribute or a span field the
   *  SDK itself owns. The `globalContext` init option (below) is seeded
   *  through the same rule, capped at 8 keys per call. */
  setGlobalContext(key: string, value: string | number | boolean): void;
  removeGlobalContext(key: string): void;
  /** Custom user action → an `action` span (attrs sanitized + scrubbed). */
  addAction(name: string, attrs?: Record<string, string | number | boolean>): void;
  /** Handled error → the same error-span path as uncaught errors. */
  addError(err: Error | string, attrs?: Record<string, string | number | boolean>): void;
  flush(): Promise<void>; // await a non-beacon delivery pass; never rejects
  shutdown(): void;  // un-instrument + final beacon flush
  /** Live SDK-health counters for THIS page (fresh per `initRum()` call).
   *  `beforeSendFailures`: how many times `beforeSend` has thrown — each
   *  such event was DROPPED, fail-closed (never the original). `consent`:
   *  `granted`/`denied`/`late_grants` transitions on this page. `failures`:
   *  counts keyed by internal failure reason. */
  diagnostics: {
    beforeSendFailures: number;
    delivery: { attempted: number; acked: number; dropped_capacity: number; dropped_error: number; handed_off: number };
    failures: Record<string, number>;
    consent: { granted: number; denied: number; late_grants: number };
  };
}

Core delivery is consent-gated. Normal exports await a 2xx acknowledgement, retry network failures, 429 and 5xx up to three attempts per delivery pass, and keep the same rum.batch.id resource attribute and payload across retries. Requests time out after 10 seconds. Backoff uses exponential jitter (starting at 250–500 ms, capped at 30 seconds); longer Retry-After values defer delivery without an early retry. Permanent HTTP failures drop with a counted reason. An injected transport should return { status, retryAfter? } or a promise of it; a legacy void return cannot confirm delivery.

Exhausted/offline batches enter an IndexedDB queue capped at 256 KiB of serialized UTF-8 records and 24 hours from creation. Flush/online drains it; byte pressure drops oldest first, age expiry counts as dropped_error. Storage failures fall back to a bounded memory queue. Withdrawal stops retries immediately, clears memory and requests durable erasure; a purge marker prevents recovery while erasure is pending. Browser storage denial can prevent immediate physical erasure. No queue, timer or network activity starts before consent. Shutdown/pagehide uses best-effort beacon; beacon acceptance is never counted as a server acknowledgement. Beacons are sent as text/plain with the key in ?api_key= (a beacon cannot set headers, and a non-safelisted type would be preflighted and silently dropped cross-origin). A batch the browser accepts for a beacon while online is handed off: counted in handed_off and never sent again (page hide fires both visibilitychange and pagehide, and a returning tab must not re-deliver it). Offline, an accepted beacon is not handed off; the batch stays queued for the online retry. A refused beacon keeps normal retries.

Delivery counters count observations (spans/vitals), not HTTP requests, per consent runtime. Recovered observations count once in that runtime; retries do not inflate them. Invariant: attempted = acked + dropped_error + dropped_capacity + handed_off + pending. rum.delivery.* resource attributes carry a snapshot in each newly sealed batch; a successful acknowledgement appears in the next batch's snapshot. These are client delivery diagnostics, not proof of backend deduplication. The stable batch ID enables ingest-side deduplication: Epok ingest (/v1/traces and /v1/metrics) acknowledges a batch whose rum.batch.id it already stored for the same tenant within the last 30 minutes with a normal 200 and does not store it again. A copy re-sent later than that (the offline queue keeps batches up to 24 hours) is stored again, and two copies arriving at the same moment can both be stored. On /v1/metrics the record of stored ids is kept per API worker process, so a retry that reaches a different worker can be stored again. Treat deduplication as a reduction of duplicates, not a guarantee of exactly-once storage.

The build emits dist/gzip-sizes.json. size-budget.test.ts rebuilds all four bundles and checks the committed baseline plus 5 KiB each. This is an SDK BYTE budget, not a claim about initialization or sustained runtime overhead. A baseline moves only through a dated, justified baselineChanges entry in size-budget.json (the test checks the sum). The 2026-10-31 entry raises epok-rum.esm.js by 340 gzip bytes for consent accounting (transition counts, the late-grant marker and counted withdrawal erasure): the base build had 3 bytes of headroom left and those additions could not be offset by trimming.

Privacy defaults

  • Dynamic URL path segments and all fragments are redacted. Plain short route labels survive; opaque, encoded or ambiguous segments are removed.

  • Exported URL query strings are scrubbed to ?<redacted> (allow-list keys with allowedQueryParams) — on fetch/XHR spans, view urls, error sources, resource urls, and string attr values passed to addAction/addError.

  • URL scrubbing limits. The path scrubber cannot tell a user name from a route label: a segment of at most 32 characters that starts with a letter and holds only letters, digits, ., _ and - — and is not hex/base64-like or a JWT — is KEPT. So /u/jane.doe, /users/alice and /profile/Jane_Doe-1985 are exported as-is, while /u/4f9c2b7e1d3a5c6b8e0f1a2b becomes /u/<redacted>. If your routes put names, handles or other readable identifiers in the path, template them:

    • Replay: startReplay({ ..., urlScrubber }). The hook runs once per recorded URL, after built-in scrubbing, and receives only the scrubbed URL (queries and fragments are already <redacted>), so it can add redaction but never remove it. A throw or non-string result records <redacted> and counts diagnostics.urlScrubberFailures.
    • Core RUM has no URL hook (byte budget). Use beforeSend, which runs last on every span and vital — span names, string attributes (http.url, view.url, view.route, action attributes) and vital routes:
    const tpl = (v: unknown) =>
      typeof v === "string" ? v.replace(/\/(u|users|profile)\/[^/?#\s]+/g, "/$1/:id") : v;
    initRum({ ..., beforeSend(ev) {
      if (ev.type === "span") {
        ev.span.name = tpl(ev.span.name) as string;
        for (const a of ev.span.attributes)
          if (typeof a.value.stringValue === "string") a.value.stringValue = tpl(a.value.stringValue);
      } else {
        ev.vital.route = tpl(ev.vital.route) as string | undefined;
        for (const k in ev.vital.attributes ?? {}) ev.vital.attributes![k] = tpl(ev.vital.attributes![k]) as string;
      }
      return ev;
    } });
    startReplay({ ..., urlScrubber: u => tpl(u) as string });

    safety-privacy.test.ts runs this recipe against the shipped core and checks that it removes the segments from the wire.

  • beforeSend runs LAST on every outgoing span/vital — drop or redact anything. A hook that THROWS fails closed: the event is dropped (never the original — a redaction hook that breaks must not ship the unredacted data it exists to strip). Each throw is counted in diagnostics.beforeSendFailures and logs one console.warn per page.

  • respectDoNotTrack honors browser DNT.

  • Sessions: keep/drop decided once per session (sessionSampleRate); dropped sessions never call the transport.

Session replay (epok-rum/replay)

Pinned rrweb 2.0.0 is bundled into replay; capture requires consent:

import { startReplay } from "epok-rum/replay";

const session = startReplay({
  consent, // same controller as core RUM
  get sessionId() { return rum.sessionId; },
  endpoint: "https://ingest.getepok.dev",
  apiKey: "epk_…",
  sampleRate: 1,            // START AT 1 while testing — your first session
                            // always uploads. Drop to ~0.1 in production:
                            // errors + slow sessions still promote themselves
  recordOnError: true,      // an error promotes the session (default)
  slowThresholdMs: 3000,    // any captured network call ≥3s promotes it (0 = off)
});

session.promote();          // promote manually (VIP user, support flow, …)

Replay options beyond the RUM table: sessionId, flushMs, maxEvents, getTraceId, path, credentials, slowThresholdMs, captureConsole, captureNetwork (fetch and XHR metadata only — method, scrubbed URL, status, duration; never bodies or headers; XHR since 0.1.7, fetch only up to 0.1.6), compress (gzip via CompressionStream), urlScrubber (see "URL scrubbing limits"; failures in diagnostics.urlScrubberFailures), masking (privacy, unmaskSelector, maskTextSelector, blockSelector, legacy maskAllInputs; see below), recordOptions (cannot override masking), and injectables (transport, random, storage).

Replay privacy levels

Behaviour change (0.1.7): replay now masks ALL page text by default. Up to 0.1.6 replay recorded every non-input text node (only .epok-mask / [data-private] were masked), so names, addresses and balances on the page were in the recording unless you tagged them. The default is now privacy: "mask-all", matching Sentry Replay's default (mask all text and inputs). Datadog's default is closer to "mask-inputs"; we chose the stricter one.

| privacy | page text | input values | display text in attributes¹ | |---|---|---|---| | "mask-all" (default) | masked (* per character, layout kept) except unmaskSelector regions | masked | masked except unmaskSelector regions | | "mask-inputs" (≤0.1.6 behaviour) | recorded except maskTextSelector | masked | recorded | | "allow" | recorded except maskTextSelector | recorded, except the always-masked fields below | recorded |

¹ title, alt, aria-label, aria-description, aria-valuetext, placeholder; the value of submit / button / reset inputs (button labels); <option> value and label; iframe srcdoc.

  • Keep the old behaviour: startReplay({ ..., privacy: "mask-inputs" }).
  • Opt public regions back in: add data-epok-unmask or class="epok-unmask" (override with unmaskSelector). This unmasks text and display attributes inside that element only. It never unmasks inputs. It does not cross a shadow root or iframe boundary from outside: a shadow host inside an unmask region still has its shadow text masked, also text added or changed later (an unmask element INSIDE the shadow root or iframe works there).
  • Always masked, at every level ("allow" included): type="password" (also after a type change on a later event), type="email", type="tel"; autocomplete containing cc-*, one-time-code, new-password, current-password, email or tel*; any input value with a run of 6 or more digits (single space, ., / or - separators allowed), so a card typed key by key never shows more than 5 digits, and phone, account and national-id numbers are masked; and anything under maskTextSelector (default .epok-mask, [data-private]) — also inside an unmask region. .epok-block omits a subtree entirely. Under "allow", the 6-digit rule also masks numeric dates typed into inputs (31/12/2024, 2024-12-31, 31.12.2024) and long numeric IDs (order, account, policy numbers); 12,345.67 (5 digits after the comma) is recorded. There are no name-based heuristics: under "allow", <input name="ssn" value="AB-12"> is recorded unless you mask it.
  • Inputs: every input value (including file names and hidden values), textarea and select goes through one masking decision; under "mask-all" button labels (submit / button / reset values) are masked as display text. Checkbox and radio state (checked or not) is recorded at every level. Up to 0.1.6, hidden and file inputs were recorded even with maskAllInputs. Legacy maskAllInputs: true masks inputs at every level; false records them (secrets above stay masked).
  • Text after the first snapshot: under "mask-all", text added or changed later — including text placed directly under a shadow root, which the bundled rrweb does not mask by itself — is masked again on the event copy unless its parent element is inside an unmask region. A node that cannot be located is masked.
  • Fail closed: each case logs one console.warn and counts diagnostics.configFallbacks. An unknown privacy value becomes "mask-all". An invalid maskTextSelector or blockSelector (rrweb would silently not apply it) forces "mask-all" with no unmask regions. An invalid unmaskSelector disables unmasking, so everything is masked.
  • HTML comments are masked under "mask-all" (snapshot, added nodes and later changes), unmask regions included; "mask-inputs" and "allow" record them.
  • Not masked by any level (known limits): stylesheet text (CSS content:), id, class, data-* and other non-display attribute values, link URLs beyond the "URL scrubbing limits" above, canvas when you enable recordCanvas through recordOptions, and, under "allow", a value set in the same task as a password→text type change (rrweb marks the field only when it processes the mutation). The build-based corpus replay-privacy-corpus.test.ts encodes the exact posture per level (fixtures/replay-privacy.md).

The session handle exposes sessionId, sampled, flush(), recordError(), promote(), stop({ flush? }), discard(), setConsent().

Delivery hardening: the final page-hide flush uses sendBeacon; if that fails, the chunk (≤256KB) is stashed in a localStorage dead-letter and retry-sent once by the next startReplay with the same config.

Recovery is bounded and best-effort — it reduces loss, it does not prevent it. What is dropped, by design:

  • Expiry. A stashed chunk older than 24 hours is discarded, never sent. A visitor who does not come back the next day loses that tail chunk.
  • One retry, then gone. The stash is removed before the retry is attempted, so a retry that itself fails does not get another attempt.
  • Ten auth contexts. Records are keyed by a hash of endpoint + API key, and at most 10 such records are kept (newest first); older ones are evicted. A record whose endpoint/key/URL no longer matches the current config is discarded rather than sent — so rotating an API key abandons anything stashed under the old one.
  • Size cap. A chunk larger than 256KB is never stashed.
  • Off entirely without an API key (cookie auth has no stable identity to key on), without WebCrypto, or without usable localStorage (private mode / quota).
  • Outage buffer (pre-existing). While uploads are failing, unsent events are re-queued in memory and capped at 10 × maxEvents (replay-capture.js); past that the OLDEST incrementals are dropped, so a long outage yields a replay whose middle is missing even though the snapshot and the recent tail survive.

Nothing here is a durable queue: treat replay delivery as lossy under page-close, storage-disabled, private-mode, and sustained-outage conditions.

Build

node build.mjs   # → dist/: esm + iife bundles for both entry points + .d.ts

Produces epok-rum.esm.js, epok-rum.iife.min.js (window.EpokRUM), epok-rum-replay.esm.js, epok-rum-replay.iife.min.js (window.EpokReplay), and type declarations (strict tsc --emitDeclarationOnly; replay.d.ts is hand-authored for the plain-JS replay module).

The SDK is built from focused, individually-tested modules (session, span, transport, instrument, views, privacy, context, perf, environment, web-vitals, trace-propagation). Run the tests with npx vitest run lib/rum/.

Changelog

See CHANGELOG.md (shipped in the package). 0.1.7 changes the replay default to privacy: "mask-all"; versions 0.1.2–0.1.5 are no longer on the registry.

Releasing

Releases are cut from a clean checkout of main, after an independent review of the exact commit. The publisher is the epok-cloud-bot npm account.

  1. Bump version, add a CHANGELOG.md entry (list every behaviour change), then node build.mjs and npx vitest run lib/rum/ from the repo root.
  2. npm pack --dry-run and check the file list (only dist/, README.md, CHANGELOG.md, package.json).
  3. Record the SHA-384 values from dist/integrity.json. npm publish runs prepublishOnly (a rebuild); the build is deterministic, so the published files must match these values. Check after publish against https://cdn.jsdelivr.net/npm/epok-rum@<version>/dist/integrity.json and the tarball before docs pin a CDN integrity= hash.
  4. Update the install docs (version pin and SRI) only after the version is on the registry.

Never unpublish. A published version is immutable for the people who installed it: an unpublish breaks every lockfile and CDN URL that pins it, and npm never allows that version number again. (0.1.2–0.1.5 were unpublished on 2026-10-23 to remove personal account details from registry metadata; installs that pinned them broke. Do not repeat this.)

If a release is bad:

  • Functional bug: publish a fixed patch version, then npm deprecate epok-rum@<bad> "<what is wrong>; upgrade to <fixed>". If the bad version is latest, move the tag: npm dist-tag add epok-rum@<previous-good> latest until the fix is out.
  • Privacy or security defect (data captured that should not be): treat as an incident. Publish the fix first, deprecate the bad range with a message that says what was captured, and record it in this changelog.
  • Leaked secret or personal data inside the tarball: rotate the secret first (a removed package is already mirrored by CDNs and caches). Only then ask npm support for removal; unpublish stays a last resort that needs the owner's written approval and a changelog entry.
  • Registry metadata (author, maintainers, email) is fixed on the npm account and in package.json for the NEXT version; it is never a reason to unpublish.