@userintuition-ai/recorder
v0.1.1
Published
Standalone rrweb-based session recording script for UserIntuition interviews. Inert until activated by a session token; no screen share.
Readme
@userintuition-ai/recorder
A standalone, customer-installable session-recording script. Customers drop a
single <script> tag on their own website; during a UserIntuition interview
that shares a link to that site, the script records the participant's session
via rrweb and streams curated activity to
the AI moderator. There is no screen share -- this script is the
alternative to it.
The script is inert on every other page load. It does nothing -- no network, no rrweb, no DOM changes beyond a marker object -- unless it finds an active session (see "Activation" below).
Install
<script async src="https://cdn.jsdelivr.net/npm/@userintuition-ai/recorder@0/dist/recorder.umd.min.js"></script>Put it once, near the end of <body> (or in <head>, it doesn't matter --
async means it won't block rendering), on every page the interview might
touch. It is safe to leave installed permanently; it is inert without an
active session.
Optional masking configuration
Add these attributes to the same <script> tag. Each takes a comma-separated
list of CSS selectors.
<script
async
src="https://cdn.jsdelivr.net/npm/@userintuition-ai/recorder@0/dist/recorder.umd.min.js"
data-ui-mask=".card-number, .ssn-input"
data-ui-block=".internal-admin-panel"
data-ui-ignore=".search-as-you-type"
></script>data-ui-mask-- elements (and their descendants) are masked: rrweb records a redacted placeholder instead of real text/values, and the curated click feed (see below) never includes their label.data-ui-block-- elements (and descendants) are fully excluded from the rrweb recording (rendered as a blocked placeholder), and likewise never contribute a label to the curated click feed.data-ui-ignore-- input elements matching these selectors have their keystrokes/values excluded from rrweb's input tracking entirely (distinct from masking: the element is still visible in the recording, its typed content just isn't captured at all). Useful for noisy, non-sensitive fields you'd rather not track (e.g. a live search box).
maskAllInputs is always on regardless of data-ui-mask/data-ui-block:
every <input>/<textarea> value is masked by default.
A note on Subresource Integrity (SRI)
The snippet above intentionally does not include an integrity="sha384-..."
attribute. @1 is an auto-updating CDN range that always serves the latest
compatible release; a pinned SRI hash would break on every new recorder
release (the hash wouldn't match the new file, and the browser would refuse
to load the script). If your security policy requires SRI, pin an exact
version instead of @1 and generate the hash yourself for that specific
build -- but then you own re-pinning it on every upgrade.
How it works
- On load,
index.tssetswindow.UserIntuitionRecorder = { version, active, stop }immediately -- a truthy marker object, even when the script stays inert. This is the detection signal other UserIntuition tooling looks for (alongside thesrccontaining "userintuition" and "recorder"). - It looks for a session in the URL fragment (
#uir=..., see below) or a previously-persisted session insessionStorage. - With neither, it does nothing else. No network call, no
rrweb.record(), no DOM changes. - With a session, it boots: strips the fragment from the URL immediately, starts rrweb recording, shows a small "Recording for UserIntuition" indicator + Stop control (shadow DOM, won't clash with page styles), and starts talking to the backend (see "Wire contract").
Wire contract
This is the shared contract with the interview call page (which produces
the fragment and speaks the other end of the channel) and the backend ingest
API (/api/concept-link-recordings/{id}/...). Keep this section and
src/wire.ts / src/session.ts / src/channel.ts in sync if either side
changes.
Ingest origin
Compiled into the bundle at build time as a constant (INGEST_ORIGIN),
never read from the fragment or any runtime source. Default:
https://api.userintuition.ai. Override at build time:
INGEST_ORIGIN=https://ingest.example.com npm run buildAll ingest calls go to ${INGEST_ORIGIN}/api/concept-link-recordings/{recording_id}/....
Session fragment
#uir=<recording_id>.<token>- The whole hash must start with
#uir=. - The script splits the remainder on the first
.only:recording_id= everything before it,session_token= everything after (the token is an opaque string and may itself contain dots). - The fragment is stripped from the URL via
history.replaceState(null, '', pathname + search)immediately upon parsing -- before anything else runs -- so the token never lingers in the address bar, browser history, or an SPA router's state. - The parsed session (
{recording_id, token, startedAt, lastSeq, signalSeq}) is persisted tosessionStorage['__uirec']so it survives full-page navigation on the same origin.lastSeq(the/chunkscounter) andsignalSeq(the/signalcounter) are separate fields, updated independently, so a resumed recorder continues BOTH sequences instead of restarting either one. On a later page load with no fragment, the script resumes fromsessionStorageinstead. With neither a fragment nor a resumable session, the script is inert.
The call page (Task 8/9) is responsible for producing this fragment when it
opens/embeds the customer page; the script never calls /start, and never
parses the token for anything beyond splitting it from recording_id -- it
is echoed back opaquely for auth.
Endpoints
All POST, base ${INGEST_ORIGIN}/api/concept-link-recordings/${recording_id}:
| Endpoint | Body | Notes |
|---|---|---|
| /chunks | canonical payload (see below) | Upload a chunk of rrweb events. |
| /signal | {type: "hello"\|"heartbeat"\|"activity", data?, seq} | Sent in both modes now (see "Channels" below). data is a curated activity object for type:"activity"; must serialize under 8KB. seq is a monotonically increasing counter shared across all three types, persisted (see below) so the backend can dedupe a retried send. |
| /poll | {cursor?} | Tab mode only. Returns {status, is_partial, activity, next_cursor, heartbeat_at}. Safe to call repeatedly (non-destructive read). |
| /finalize | {final_seq} | Sent by the script once it observes the interview closing (see "Channels"), after draining the outbox. |
| /abort | {reason} | The script only ever sends reason: "participant_stopped" (the Stop control). Other reasons (handshake_timeout, heartbeat_lost, screen_share_fallback, call_cancelled) are sent by the call page, not this script. |
The script never calls /start (the call page does, to mint the fragment)
and never calls /close (parent-initiated, not a script action).
A 409 from any endpoint means the recording is already finalized/aborted server-side. The script stops immediately and clears its session -- it never retries after a 409.
Canonical /chunks payload and dual transport
The canonical payload is:
JSON.stringify({ seq, events }) // serialized exactly onceThis exact string is reused, byte-identical, across both transports:
- Normal upload (
fetch):Authorization: Bearer <session_token>,Content-Type: application/json, body = the canonical payload string itself (not wrapped in anything). - Unload drain (
sendBeacon):sendBeaconcannot set headers, so the token travels inside the body instead:JSON.stringify({ session_token, payload }), wherepayloadis the canonical payload string (nested as a string, not a re-parsed object). The script checkssendBeacon's boolean return --falsemeans the browser didn't accept the send, and the chunk stays queued for a later retry rather than being dropped.
seq is monotonically increasing starting at 1. final_seq (sent to
/finalize) is the last seq produced.
Channels: two modes, chosen once at boot
iframe mode --
window.parent !== window(the recorder page is embedded in an iframe on the call page). Hello goes only to the parent viapostMessage. Heartbeat goes to both the parent (viapostMessage, since the parent still needs it directly for its own screen-share-fallback detection) andPOST /signal(the backend batches activity server-side and pushes it into the live call, which needs to keep working even when the parent tab is backgrounded). Curated activity isPOST /signal-only -- it no longer goes viapostMessageat all:parent.postMessage( { source: 'ui-recorder', token, recordingId, type, payload }, parentOrigin // resolved from location.ancestorOrigins[0] or document.referrer );Incoming messages are only accepted if
event.originmatches the resolved parent origin (or is inlocation.ancestorOrigins) andevent.data.tokenmatches this session's token. The parent sends{type: 'close', token}(drain the outbox, thenPOST /finalize {final_seq}) or{type: 'ack', token}(informational, no action).tab mode -- top-level window (opened via
window.open, no reachable parent). Hello/heartbeat/curated-activity all go toPOST /signal(there's nothing to postMessage). The script pollsPOST /pollevery ~3s to readstatus:'closing'→ drain the outbox, thenPOST /finalize {final_seq}.'aborted'or'complete'→ stop immediately and clear the session (no finalize call).
In both modes, /chunks uploads are always used (durable, regardless of
channel mode) and a heartbeat is sent every ~5s.
/signal sends are serialized through a single promise chain per Channel
instance (never overlapping in-flight POSTs), so a retry can't be reordered
against a later send. An activity send that gets back {ok:false} (network
failure, non-2xx) is retried a bounded number of times with the same
seq -- a genuinely-lost send is re-delivered, and a response-lost retry is
deduped server-side by that unchanged seq. hello/heartbeat are
best-effort/single-attempt on purpose: a dropped heartbeat is superseded by
the next one ~5s later, and retrying it would sit in front of later sends in
the serialized chain and delay them.
pagehide behavior (important)
On pagehide, the script only drains whatever's queued in the outbox via
sendBeacon to /chunks (best-effort). It never calls /finalize,
/close, or /abort from a pagehide-driven path -- a page unload is not a
signal that the interview is over (the participant might navigate to another
page on the same site and the session will resume from sessionStorage
there). Finalization only ever happens via the explicit channel signals
above (parent close message, or poll status: 'closing').
Self-limits
The script stops itself (and clears the session) if:
- 10 consecutive
/chunksupload failures occur (network down, endpoint unreachable, CORS misconfigured, etc.). - Any
/chunkscall returns 409 (finalized/aborted server-side). - 2 hours elapse since boot.
- The participant clicks Stop (sends
POST /abort {reason: 'participant_stopped'}first, then clears).
window.UserIntuitionRecorder.stop() triggers the same participant-stop flow
programmatically.
Module structure (src/)
| Module | Responsibility |
|---|---|
| index.ts | Sets the marker immediately; auto-init: parse fragment/resume session, boot or stay inert. |
| session.ts | Fragment parsing, immediate stripping, sessionStorage persistence/resume. |
| outbox.ts | Bounded IndexedDB (or in-memory fallback) queue of unacked /chunks; replay-on-boot; pagehide drain via sendBeacon. |
| recorder.ts | Wraps rrweb.record(); batches events into chunks; owns the seq counter. |
| curated.ts | Derives click / rage-click / navigation activity from the DOM + history, respecting mask/block selectors. |
| channel.ts | iframe-postMessage vs tab-/signal+/poll mode; auth + origin validation. |
| consent.ts | Shadow-DOM recording indicator + Stop control. |
| wire.ts | Shared canonical payload builder, endpoint URLs, fetch/beacon transport helpers. |
| masking.ts | Shared mask/block selector matching used by both recorder.ts and curated.ts. |
| config.ts | Build-time constants and tuning knobs (intervals, caps, self-limits). |
Build
npm install
npm run build # tsup -> dist/recorder.umd.min.js (IIFE, minified) + dist/recorder.esm.js
npm run size # reports the gzipped size of the IIFE bundle against the 35KB budgetrrweb is pinned to exactly 2.0.0-alpha.4 to byte-match the version the
main frontend's rrweb-player uses for replay -- do not bump this
independently of the frontend.
Test
npm test # jest + jsdom
npm run typecheck # tsc --noEmitDemo
A tiny multi-page static site under demo/ exercises the script locally
(no real backend required -- network calls will fail, which is expected and
demonstrates the self-limit behavior).
npm run demo # builds, copies the bundle into demo/vendor/, and serves demo/ on :4444Open http://localhost:4444/, then use the "Start a demo recording session"
link (carries a #uir=... fragment) to activate the recorder, and navigate
between pages to see the session resume from sessionStorage.
License
UNLICENSED / proprietary -- internal UserIntuition package.
