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.
Maintainers
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 asdenied, 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 carryconsent_granted_late: true, anddiagnostics.consent.late_grantsincrements. (The replay field is not stored server-side yet.) - Withdrawal (
setConsent("denied"), or replaydiscard()): 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, replaydiagnostics.droppedEvents), and consent transitions indiagnostics.consent(granted/denied). The coredeliverycounters belong to the current runtime, so after a re-grant they restart from zero. - Ordinary
shutdown()/ replaystop()still flush while granted. Replaystop({ 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 onperformance.timeOrigin)end=entry.loadEventEnd→ attributerum.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"andrum.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 correctionweb-vitalsapplies), clamped at 0, and the span is stampedrum.prerendered: "true". A page that finished loading before activation reports ~0: the user waited ~0. initRum()does nothing at all whiledocument.prerenderingis true — no session, no exporter, no spans. It initializes onprerenderingchange, replaying any handle calls made meanwhile. A prerender that is never activated emits nothing, and the handle'ssessionIdis""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 withallowedQueryParams) — on fetch/XHR spans, view urls, error sources, resource urls, and string attr values passed toaddAction/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/aliceand/profile/Jane_Doe-1985are exported as-is, while/u/4f9c2b7e1d3a5c6b8e0f1a2bbecomes/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 countsdiagnostics.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.tsruns this recipe against the shipped core and checks that it removes the segments from the wire.- Replay:
beforeSendruns 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 indiagnostics.beforeSendFailuresand logs oneconsole.warnper page.respectDoNotTrackhonors 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-unmaskorclass="epok-unmask"(override withunmaskSelector). 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";autocompletecontainingcc-*,one-time-code,new-password,current-password,emailortel*; 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 undermaskTextSelector(default.epok-mask, [data-private]) — also inside an unmask region..epok-blockomits 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
inputvalue (includingfilenames andhiddenvalues),textareaandselectgoes through one masking decision; under"mask-all"button labels (submit/button/resetvalues) are masked as display text. Checkbox and radio state (checked or not) is recorded at every level. Up to 0.1.6,hiddenandfileinputs were recorded even withmaskAllInputs. LegacymaskAllInputs: truemasks inputs at every level;falserecords 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.warnand countsdiagnostics.configFallbacks. An unknownprivacyvalue becomes"mask-all". An invalidmaskTextSelectororblockSelector(rrweb would silently not apply it) forces"mask-all"with no unmask regions. An invalidunmaskSelectordisables 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 enablerecordCanvasthroughrecordOptions, 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 corpusreplay-privacy-corpus.test.tsencodes 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.tsProduces 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.
- Bump
version, add aCHANGELOG.mdentry (list every behaviour change), thennode build.mjsandnpx vitest run lib/rum/from the repo root. npm pack --dry-runand check the file list (onlydist/,README.md,CHANGELOG.md,package.json).- Record the SHA-384 values from
dist/integrity.json.npm publishrunsprepublishOnly(a rebuild); the build is deterministic, so the published files must match these values. Check after publish againsthttps://cdn.jsdelivr.net/npm/epok-rum@<version>/dist/integrity.jsonand the tarball before docs pin a CDNintegrity=hash. - 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 islatest, move the tag:npm dist-tag add epok-rum@<previous-good> latestuntil 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.jsonfor the NEXT version; it is never a reason to unpublish.
