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

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

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

Install
npm install @keploy/capture-sdkA 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 queuedinit 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.sensitiveHeadershave their values replaced with[REDACTED]. The default list isauthorization,cookie,set-cookie,x-api-key,x-auth-tokenandproxy-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,cvvand the rest of that family, including suffix forms likeuserPasswordandauthToken— 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: truereplaces 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,signatureorsessionidare 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'sContent-Lengthfor a body it never downloaded. So a note is not a way to size a body againstmaxBodySize. There are seven, one for each way a body can be one the SDK does not read:[Blob: N bytes, truncated]— afetchBlob over the bound.[Blob: N bytes]— any Blob sent throughXMLHttpRequest, which serialises its body insidesend(), 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 aContent-Lengthover 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]— anXMLHttpRequestwhoseresponseTypemakesresponseTextthrow. 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.randomfallback for the session nonce, so a recording is either joinable or says why it is not. - No sampling by user.
sampleRateis decided per session start.
Docs and help
- UI Flow Capture overview — how recordings become flows, generated tests and verdicts
- Installing the SDK — this page, in more detail
- Keploy documentation
- Keploy community Slack
- [email protected]
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.
