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

@keploy/capture-sdk

v3.0.1

Published

Lightweight SDK to capture UI flows from real users - DOM recording, network interception, PII masking

Readme

@keploy/capture-sdk

The browser SDK for Keploy UI Flow Capture. It records what a real user does in your web app — the DOM through rrweb, and every fetch and XMLHttpRequest exchange the page makes — masks personal data before anything leaves the browser, and posts the recording in batches to Keploy. Keploy turns the recordings into deduplicated flows and, from those, into Playwright tests that replay against recorded mocks.

This package runs inside your bundle, on your users' machines, which is why it is Apache-2.0 while the rest of the product is proprietary. It is the only part of UI Flow Capture on npm: the tests are generated and run by the keploy binary, not by another package.

What ships today

The version is the git tag's -- the committed manifest says 0.0.0-tag and the release lane writes the tag's version in before it publishes (RELEASING.md in the repository; sdk.version on every batch is that number). The pipeline behind the SDK is mid-migration. The SDK records, masks and posts as this page describes. What happens to a recording after it arrives does not all exist yet: the ingest route and the app-bound keys it needs are on an unmerged branch of the Keploy API server, so no deployment has them; flow extraction writes one placeholder flow with no steps; Generate Test produces no files and Run Test executes nothing. The overview has the part-by-part list under "What ships today", and the screenshots below show what the dashboard will do with a recording, not what today's pipeline produces from one.

Those docs pages, and the images below, publish with the docs release that accompanies this package. If you are reading this before that lands, they 404; [email protected] has the current state.

What you get

Recorded sessions become flows in the Keploy dashboard:

The UI Flows list: each flow with its page path, step count, how many sessions matched it, and its verdict on the last run

Each flow is the sequence of steps a user took, with every network call those steps made recorded beside them:

A flow's detail page: the recorded steps down the left, the network calls captured for the flow on the right

And every run of the generated tests reports one verdict per flow — passed, failed, blocked, and the rest of the closed verdict set:

The test runs page: one row per run with its per-flow verdicts and the worst-wins rollup

Install

npm install @keploy/capture-sdk

A runtime dependency, not a dev one: it runs in production, on the page. Its only dependency is rrweb, which your bundler resolves; there are no peer dependencies. The package ships ESM and CommonJS (dist/index.mjs, dist/index.js) with types. The bundle is 26.2 kB (decimal) gzipped with rrweb excluded — MEASURED on the publish build (tsup src/index.ts), and a test holds both that figure and the 30 KiB ceiling this package is budgeted at. There is no script-tag build: the only way in is an import.

Use

One call, as early in your app's startup as you can make it:

import { KeployCapture } from "@keploy/capture-sdk";

await KeployCapture.init({
  endpoint: "https://api.keploy.io/ui/v1/ingest",
  apiKey: "<your app's UI ingest key>",
});

endpoint is the ingest route of the Keploy API server your workspace uses. apiKey is sent as the X-API-Key header on every batch. It is a UI ingest key: issued for one app, it can post recordings into that app and do nothing else — no reads, no other API — which is what makes it safe to ship inside a page every visitor can read. Do not put any other kind of Keploy credential here.

Those two are the only required fields. The others, with the value the SDK uses when you leave them out:

| option | default | what it does | | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | privacyMode | "balanced" | "balanced" masks every typed input value; "strict" also masks every visible text node; "off" masks only password and hidden fields — see Privacy | | sampleRate | 1 | fraction of sessions recorded, decided once when a session starts. 0.1 records one visitor in ten; the rest never start | | networkCapture | true | record fetch and XMLHttpRequest exchanges. false records the DOM only | | maxSessionDuration | 600000 | milliseconds after which the capture ends on its own (10 minutes): recording stops, the queue is flushed, and a later init() starts a new session | | batchSize | 50 | events per POST, as a ceiling: clamped to 5000, the most the ingest server accepts in one batch, and a batch is also cut at the server's 10 MiB body cap, so large events make more, smaller POSTs rather than one the server would refuse | | batchInterval | 5000 | milliseconds between flushes. Each flush sends ONE batch, so a backlog drains at a batch per interval | | maxQueueSize | 5000 | events held in memory while waiting to send; past this the oldest are dropped | | maxBodySize | 524288 | how much of a request or response body is recorded (512 KB), counted in UTF-16 code units — see What a large body looks like | | maskSelectors | — | CSS selectors whose matched elements have their text masked, including every text node below them. Input values are decided by privacyMode alone | | unmaskSelectors | — | CSS selectors whose text is left in the clear, overriding maskSelectors and "strict" for the subtree below them. It cannot unmask an input value | | privacy | — | a Partial<PrivacyConfig>: maskSelectors, unmaskSelectors, blockSelectors, maskNetworkBodies, sensitiveHeaders, piiPatterns — see Privacy | | sessionIdGenerator | — | supply your own session id instead of the SDK's UUID v4, to correlate with your own systems. It must be a string of 1-200 characters of letters, digits, dot, dash and underscore with no empty dot-separated segment (tenant-7.user_42.run-3; encode a user:42/session 1 first) — the SDK checks what the generator produces before sending and refuses to start on anything else, a throw included (KUI_E_SESSION_ID_INVALID). Two live sessions with one id are refused by the server (a terminal 409) | | onError | — | called once when a terminal condition ends the capture — see When the server rejects a batch |

After init, the static surface is:

KeployCapture.pause(); // keep the session, stop emitting events
KeployCapture.resume();
KeployCapture.getSessionId(); // "" when no session is live
KeployCapture.getCaptureIdentity(); // the browser half of the backend join, or why there is none
KeployCapture.stop(); // stop recording and send what is queued

init returns the same instance on a second call; stop clears it so a later init starts a new session.

A hidden tab pauses DOM recording and nothing else — the exchanges the page makes are still recorded, because the page still makes them and they are what a replay needs. When the tab comes back, the SDK takes a fresh full snapshot only if the paused recording dropped something: rrweb has no pause, so it kept observing while the tab was hidden, and the mutations that were dropped would otherwise leave the events after them naming nodes the recording never showed. A hide that dropped nothing has nothing to re-baseline and takes none, so a visitor who alt-tabs thirty times on a page that is not mutating uploads no snapshots for it. pause() is the stronger one and stops everything, and resume() re-snapshots under the same condition.

What network capture is gated on is the rrweb import. init() awaits import("rrweb") before it patches fetch and XMLHttpRequest, and in your bundle that is normally a separate chunk fetched over the network. Until the import resolves, fetch and XMLHttpRequest are still the browser's own: a request the page makes in that window is not recorded at all, in a hidden tab exactly as in a visible one. Calling init() as early in startup as you can narrows that window; nothing closes it.

Privacy

Masking happens in the browser, before transport. Nothing here can be undone on the server, because the server never sees the original.

In the DOM. privacyMode sets what rrweb records.

| input type | balanced | strict | off | | ------------------------------------------------------------------------------------------------------------------------------ | ---------- | -------- | ------ | | password | masked | masked | masked | | hidden | masked | masked | masked | | color, date, datetime-local, email, file, month, number, range, search, tel, text, time, url, week | masked | masked | kept | | <textarea>, <select> | masked | masked | kept | | visible text nodes | kept | masked | kept |

hidden is masked in every mode, off included: a hidden field is machinery the user never sees or types into — a CSRF token, a session id, an internal record id — so off, which is about what the user typed, is not asking for it.

file is the name of the file the person picked — rrweb records the input's value, and that value is C:\fakepath\Resume_John_Smith.pdf once a file is chosen. It follows the mode like anything else the person entered.

Those two are the reason this SDK hands rrweb its own map: rrweb's maskAllInputs covers the other sixteen rows and neither of these.

radio, checkbox, submit, button, reset and image are not in that table: their value is recorded verbatim in every mode, strict included. For the first four nothing can reach it — rrweb serialises those four before masking is consulted, so no option this SDK could pass would change them. reset and image could be masked and deliberately are not: a reset button's value is the caption it shows, and an image button's is metadata the page's author wrote, neither being anything the person using the page entered. For the others the value is usually a label too. When it is not — a checkbox whose value is an email address, say — the only lever is privacy.blockSelectors, and that removes the element from the recording altogether.

privacy.blockSelectors names elements whose content is never recorded — a video, a chat widget, a wall of customer names — rrweb records an empty box of the same size in their place.

maskSelectors masks the text of the elements it matches and of everything below them; unmaskSelectors is the exception to both it and strict, for the subtree below what it matches. Both are text only: nothing you can name a selector for will un-mask an input value. Each has a top-level spelling and a privacy. one, and the two lists are merged rather than one taking precedence.

On the network. For every recorded exchange:

  • Headers named in privacy.sensitiveHeaders have their values replaced with [REDACTED]. The default list is authorization, cookie, set-cookie, x-api-key, x-auth-token and proxy-authorization; a list you pass extends it and cannot shrink it: those six are redacted whatever you configure, because no redacted header value is ever needed for replay. Every other header value is scanned for the PII patterns below.
  • Request and response bodies are redacted structurally: JSON is parsed and every value under a sensitive key — password, token, secret, apikey, session, cookie, ssn, cardnumber, cvv and the rest of that family, including suffix forms like userPassword and authToken — is replaced, then the JSON is re-serialised so the recording still parses. Other bodies are scanned as text for JWTs, email addresses, SSNs, phone numbers, IPv4 addresses and card numbers. privacy.maskNetworkBodies: true replaces every body wholesale with [MASKED] instead.
  • URLs keep their path — it is what the replay key is computed from — and the values of query parameters named like token, password, secret, apikey, signature or sessionid are redacted by name. The fragment (#...) is dropped entirely: it never reaches a server, so no replay needs it, and single-page routers put user state there.

The built-in patterns match JWTs, email addresses, US phone numbers, US social-security numbers and IPv4 addresses; card numbers are detected structurally (issuer prefix, length and Luhn), because a length-only pattern matched every 13-digit timestamp. The same set runs over header values, over string values inside JSON bodies, and over non-JSON bodies as a whole — one scanner, three places.

privacy.piiPatterns: [{ name, pattern }] adds your own, and they run wherever that scanner does. Every occurrence is replaced whether or not your pattern carries g, and never a JSON key or a number, so the body still parses.

They do not run over URLs, and that is deliberate: the path and the query values are what the replay key is computed from, so rewriting them makes the recording unmatchable. Put an identifier that must be scrubbable by pattern in a header or the body.

PrivacyEngine is exported if you want to run the same masking on your own strings:

import { PrivacyEngine } from "@keploy/capture-sdk";

new PrivacyEngine().maskText("contact [email protected] now"); // "contact [REDACTED] now"

What a large body looks like

maxBodySize is a bound on what is recorded, not an exact byte budget: it counts UTF-16 code units, which is what a JS string is measured in — equal to bytes for ASCII and up to 2x under it for multi-byte text. Counting bytes would mean encoding every body the page sends just to decide whether to keep it.

Over the bound, there are three answers, not one:

  • Text the SDK read is cut at the bound and marked ...[truncated].
  • A cut body that was JSON no longer parses. There is no safe way to redact a fragment — the defect this replaced was a pattern matching inside a serialised number, turning {"ts":1699999999999} into {"ts":[REDACTED]}, which no longer parses either — so it is refused whole and recorded as [UNPARSEABLE_BODY_NOT_REDACTED]. Never partly redacted, and never left in the clear.
  • A body the SDK does not read is recorded as a note naming what it was, with a size where there is one to give. Not every note has one: a streaming request body cannot be sized without consuming it, and the notes for a response that could not be read as text and for a body the SDK never read carry none either. Where a note does carry a size it is the figure the SDK had, not always a measurement — [Response body too large: N bytes] repeats the server's Content-Length for a body it never downloaded. So a note is not a way to size a body against maxBodySize. There are seven, one for each way a body can be one the SDK does not read:
    • [Blob: N bytes, truncated] — a fetch Blob over the bound.
    • [Blob: N bytes] — any Blob sent through XMLHttpRequest, which serialises its body inside send(), where reading a Blob (an async call) would mean holding the request up.
    • [Binary: N bytes] — a buffer over the bound, or one that is not UTF-8 text. A buffer that is text and fits is recorded as that text.
    • [Response body too large: N bytes] — from a Content-Length over the bound, so the body is never downloaded.
    • [ReadableStream] — a streaming request body, which reading would consume out from under your app. No size: measuring a stream means consuming it too.
    • [<responseType> response] — an XMLHttpRequest whose responseType makes responseText throw. No size: the response is there in a form this note does not measure.
    • [Unreadable body] — the fail-closed answer: a body that could not be read at all is never passed through. No size: nothing here was read, so there was nothing to measure.

Lower maxBodySize to save bandwidth and you will get the second answer for most large JSON, not a shorter body. If you need the shape of a large response more than its contents, that is the trade.

What leaves the browser

Each POST carries: the session id; a batch of events — rrweb DOM events and the masked network exchanges (method, URL, headers, bodies, status, duration); the page metadata (URL, user agent, viewport, referrer, language, device pixel ratio, start time); the SDK name and version; and the capture identity that lets Keploy join this recording to a backend recording of the same requests — the session id, the page's origin, a start timestamp, and a nonce minted with crypto.getRandomValues or not at all (without a CSPRNG the recording is still made; it is just not joinable). Payloads are gzipped when the browser has CompressionStream.

The metadata and the capture identity ride on every batch, not just the first. A batch is the only thing the server is guaranteed to receive, and either of them arriving only with the first one is lost whenever that batch is the one that fails. They are a few hundred bytes against a batch of up to 50 events.

Nothing is sent to anyone but the endpoint you configured.

An event that JSON.stringify refuses never leaves at all — a custom event holding a circular reference or a BigInt. Every event is measured as it is queued, so such an event is dropped there rather than thrown back into the code that emitted it; so is an event that alone is over the ingest server's 10 MiB body cap (an rrweb snapshot of a very large page, a captured body under a raised maxBodySize), which no batch could carry. The first of each kind in a capture logs a console.error; any after it are dropped silently, because an emitter in a render loop would otherwise write a line every few milliseconds into the page the SDK is there to record. onError is not called — that is reserved for a condition that ENDS the capture, and this session goes on without the event. So a recording is complete only for what serialises and fits, and that one console line is the whole of the notice — which is why it carries a code and its evidence:

[keploy] KUI_E_EVENT_OVERSIZED: dropped an event: 11059200 bytes in a body, over the ingest server's 10485760-byte cap, so no batch could carry it. The capture continues; any further such events in this capture are dropped silently. evidence={"eventId":"7","eventType":"dom","bytes":11059200,"cap":10485760}

KUI_E_EVENT_UNSERIALISABLE (evidence eventId, eventType, reason) and KUI_E_EVENT_OVERSIZED (evidence eventId, eventType, bytes, cap) are warning-severity entries of the same error corpus as the codes above, and SDK_WARNINGS is the SDK's copy of their summaries and remedies; a program that only has the console can match the token and parse the JSON after the first evidence= (the line is one line, and nothing before the marker is yours: the event's id and type are in the JSON, not in the prose). A new init() starts a new count: once per capture, not once per page.

The last batch

When the page unloads — a navigation, a tab close — whatever is still queued is sent in one keepalive request from the pagehide handler. Browsers cap the sum of a document's in-flight keepalive bodies at 64 KiB (MEASURED in Chromium 145: 65,536 bytes accepted, 65,537 refused), and a request over that is refused before it leaves, losing everything in it. So that last request is cut at the first event that does not fit, oldest first, and what does not fit stays behind — an event held back is one whose node ids the events after it depend on, so shipping past it would be worse than stopping there.

What is guaranteed: every event the SDK has accepted and not yet posted is visible to that last request, including a batch the transport had already taken off the queue and not yet sent (one being gzipped, or waiting out a retry backoff). It is put back at the front so the cut sees the session in order.

What is not: a request already on the wire when the document dies. A keepalive request survives the document, so it usually arrives — but if it does not, the SDK cannot know and cannot resend it. And an event whose JSON, with the envelope that wraps it, exceeds the 64 KiB cap cannot travel this way at all; the SDK posts such an event the moment it is queued rather than leaving it to be cut here — but not while the browser reports itself offline. The post is deferred then to the online event, which drains the queue rather than sending the one batch a timed flush sends. A navigation before that event still runs the unload flush, and this event is precisely what that flush cannot carry: it is lost, with everything queued behind it and anything ahead of it that does not fit the single batch that flush sends — the failure the immediate post exists to close, open for as long as the browser says it is offline. And on a slow uplink a navigation can still cancel the post that did go out. maxBodySize bounds a network event's size, and privacy.blockSelectors keeps subtrees out of the DOM snapshot.

When the server rejects a batch

A 4xx other than 429 is terminal: a revoked key, a key for another app, or a second run claiming a session id that is already live. The SDK ends the CAPTURE — recording stops, fetch and XMLHttpRequest are unpatched, and the singleton is released — logs a console.error carrying the server's own explanation, and calls onError once with a CaptureError. It does not retry into a permanent rejection and it does not quietly produce an empty recording; and because the singleton is released, a later init() with a working key starts a new session rather than handing back the one that failed.

The CaptureError is for the program, not just the person reading the console. Branch on code, never on the message:

| code | the server answered | what to do (remedy.manual, in short) | | --------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | KUI_E_INGEST_KEY_REJECTED | 401 or 403 | the key is missing, unknown, expired, revoked, or not a UI ingest key (a personal access token is refused) — mint one with the api-server GraphQL mutation createUIIngestKey(appId, name, ttlDays) and pass it as apiKey | | KUI_E_INGEST_ENDPOINT_NOT_FOUND | 404 or 405 | endpoint is wrong, not the key — the ingest route is <api-server>/ui/v1/ingest. Only a typo under /ui/v1/ or a same-origin proxy produces this; a wrong endpoint on another origin fails its CORS preflight, which the SDK can only retry as a network error, so silence is not proof the endpoint is right | | KUI_E_SESSION_CONFLICT | 409 | this session id is already claimed — give each run its own, usually by removing a fixed-value sessionIdGenerator | | KUI_E_INGEST_REJECTED | any other terminal 4xx | read serverMessage; ui session has reached its size limit means stop and re-init for a new session, anything else is an SDK/server version mismatch (the SDK checks the session id, clamps batchSize and cuts every batch at the 10 MiB body cap before sending, so nothing you configure produces one) and issues names the field | | KUI_E_SESSION_ID_INVALID | nothing — raised before the first byte | sessionIdGenerator returned a string outside the server's rule (evidence.rule), returned a non-string, or threw — evidence.received says which; make it return a string inside the rule, or remove the override |

onError receives one of two subclasses of CaptureError, and branching on code narrows to it, so a TypeScript handler can read the evidence the remedy names. An IngestRejection (the four server-answered codes) carries status, serverMessage (the error field of the server's JSON body, or the body's text, or null), issues (the server's [{ path, message }] when its body had them — a 400 says which field — or null), remedy ({ command, manual } — command is null on all five, because no build has a verb that mints a key, edits the endpoint or rotates a session), and evidence ({ status, serverMessage, endpoint, sessionId } plus issues when sent, exactly the fields the corpus declares). A SessionIdRefusal (KUI_E_SESSION_ID_INVALID) is raised by the SDK itself before anything is sent, so its status is null and its evidence is { sessionId, received, rule } — the string the generator returned or null, what it produced (number 42, object {}, threw Error: boom), and the rule. The five codes, their summaries, remedies and evidence are the entries of the same name in @keploy/contract's error corpus; the SDK carries a copy because rrweb is its only runtime dependency, and a test holds the copy to the corpus. The status-to-code mapping is spec/sdk-errors.v1.json; the session-id rule is spec/session-id.v1.json, the api-server's rule stated once — the SDK and the sidecar execute the file today, and the api-server's own executor lands with its next vendoring of the corpus. onError runs after the capture has ended and the singleton is released, so a handler may call init() again and get a fresh capture:

import { KeployCapture } from "@keploy/capture-sdk";

await KeployCapture.init({
  endpoint,
  apiKey,
  onError: (err) => {
    if (err.code === "KUI_E_SESSION_ID_INVALID")
      fixGenerator(err.evidence.received);
    else if (err.code === "KUI_E_INGEST_KEY_REJECTED") rotateKeyAndReload();
    else
      report(
        err.code,
        err.serverMessage,
        err.evidence.issues,
        err.remedy.manual,
      );
  },
});

A 429, a 5xx or a network failure gets three attempts: up to 1 s between the first and the second, up to 2 s between the second and the third, and then — if the third fails too — up to a further 4 s before the batch goes back to the front of the queue. Two gaps between three attempts; the last wait delays the re-queue rather than an attempt.

What it deliberately does not do

  • No replay, no assertions, no selectors. The SDK records raw and never normalises what it saw; the server does that once at ingest, so record time and replay time cannot disagree.
  • No Math.random fallback for the session nonce, so a recording is either joinable or says why it is not.
  • No sampling by user. sampleRate is decided per session start.

Docs and help

License

Apache-2.0. The full text ships in this package: see LICENSE. The rest of Keploy UI Flow Capture is proprietary; this package is the part that runs in your own bundle.