@replayio-app-building/session-recorder
v0.17.1
Published
Capture browser sessions and create Replay recordings via simulation
Readme
@replayio-app-building/session-recorder
Capture browser sessions and create Replay recordings via simulation. This package provides a client-side API for capturing DOM events in the browser and a server-side API for storing session data, triggering recording creation, and managing recordings. It also includes a simulate-from-a-recording API for recreating a new recording from any existing base recording — replaying its session data (extracted or reconstructed) while gathering rrweb and injected-script data.
Installation
npm install @replayio-app-building/session-recorderClient API
Import from @replayio-app-building/session-recorder.
Early initialization
The session recorder intercepts localStorage, indexedDB, fetch, and WebSocket via proxies. If your app reads from localStorage at module scope (e.g. to restore an auth token), those reads happen during JS module evaluation — before any function call in your entry file can run. To ensure the recorder captures these reads, import the init entry point before any application modules:
// main.ts — init MUST be the first import
import "@replayio-app-building/session-recorder/init";
import { store } from "./store"; // module-scope localStorage reads are now captured
import App from "./App";The init import installs all capture proxies as a side effect. You still call startSession() later to get a session handle.
startSession(options?): SessionHandle
Begins capturing DOM events (mouse, keyboard, scroll, resize, mutations) in the current browser window. Returns a handle for retrieving the captured data. Calls initializeCapture() internally if it hasn't been called yet (e.g. via the init import).
import "@replayio-app-building/session-recorder/init";
import { startSession } from "@replayio-app-building/session-recorder";
const session = startSession();
// ... user interacts with the page ...
const data = session.getSessionData();Options (StartSessionOptions, all optional):
captureSourceMaps?: boolean— Defaultfalse. Whenfalse,sourceMappingURLcomments in captured text responses are not followed. Source maps are never requested by the page itself and carry no page-reconstruction value, so following them by default would charge every recorded session a needless multi-MBmax-age=0download. Set totrueonly if a downstream tool genuinely needs the maps inlined per session. Passing this to a laterstartSession()still takes effect even ifinitializeCapture()already ran via the/initimport.
Returns: SessionHandle with:
sessionId— A UUID identifying this page-load capture session. Stable for the tab's lifetime. Do not reuse it for more than one upload — each recording must have its own id, otherwise the second upload collides on the server'ssession_recorder_dataprimary key and is rejected. Usesnapshot()instead.getSessionData()— Returns the capturedSimulationData(array ofSimulationPacketevents)snapshot()— Returns{ sessionId, sessionData, releasedCount }wheresessionIdis a fresh UUID minted on every call,sessionDatais the cumulative capture buffer up to the current endpoint, andreleasedCountis the number of packets already dropped viarelease()before this snapshot (0unless you userelease()). Call this once per bug report / upload so every recording is stored under its own unique id while the single page-load buffer keeps accumulating.release?(count)— Optional / opt-in. Drops the firstcountpackets — which you have already durably uploaded — from the front of the capture buffer and returns the new buffer length. Use this only in a streaming/continuous-upload consumer that POSTs data as it goes and wants memory to stay flat over a long session; it keeps the buffer from growing without bound. After releasing,snapshot().releasedCountreports how many packets preceded the returned buffer so the truncation is explicit rather than silent. Most consumers (onesnapshot()per bug report) never need this.
Recording multiple sessions in one page load
startSession() mints one long-lived capture buffer per page load. A user may file several bug reports without reloading — each is a distinct recording (page-load → T1, page-load → T2) covering a different window, so each needs its own id. Take a snapshot() per report:
const session = startSession();
// First bug report — its own id, cumulative data so far.
const a = session.snapshot();
await uploadSession(a.sessionId, a.sessionData);
// ... user keeps interacting (the same buffer keeps growing) ...
// Second bug report — a NEW id, the now-larger cumulative buffer.
const b = session.snapshot();
await uploadSession(b.sessionId, b.sessionData);Reusing session.sessionId for both uploads instead would make the second upload collide on the session_recorder_data primary key (the server answers 409 Conflict) and lose that recording.
markEndpoint(): void
Marks the end of the session recording. When getSessionData() is called, only events captured before the endpoint are included. This is useful when the interesting part of a session ends before the page is closed — for example, after a form submission completes or a checkout flow finishes.
import { startSession, markEndpoint, uploadSession } from "@replayio-app-building/session-recorder";
const session = startSession();
// ... user completes the checkout flow ...
markEndpoint();
// ... page may continue with confirmation animations, etc. ...
const data = session.getSessionData(); // only includes events before markEndpoint()
await uploadSession(session.sessionId, data);If markEndpoint() is never called, getSessionData() returns all captured events as before. Calling markEndpoint() again moves the endpoint to the current position, so the last call wins.
uploadSession(sessionId, sessionData, endpoint?): Promise<{ session_id: string }>
Compresses and uploads captured session data to the server. The data is gzip-compressed before sending.
import "@replayio-app-building/session-recorder/init";
import { startSession, uploadSession } from "@replayio-app-building/session-recorder";
const session = startSession();
// ... capture interactions ...
const data = session.getSessionData();
const result = await uploadSession(session.sessionId, data);
console.log(result.session_id); // UUID of the stored sessionParameters:
sessionId— The session UUID (fromstartSession().sessionId)sessionData— The capturedSimulationDataarrayendpoint— Upload URL (default:"/api/upload-session")
Large sessions:
uploadSessionsends the whole gzipped session in one POST, which the platform rejects before the handler runs once the body exceeds its request-body cap. Any page with sizeable assets/responses can hit this. For those, useuploadSessionStreaming— it uploads the complete session in ordered sub-cap chunks (no data dropped) and stores the identical artifact.
uploadSessionStreaming(sessionId, sessionData, options?): Promise<{ session_id: string }>
Streaming alternative to uploadSession. uploadSession sends the whole gzipped
session in a single POST, so sessions larger than the platform's synchronous
request-body cap (~6 MB on Netlify) fail before the handler runs.
uploadSessionStreaming removes that ceiling by splitting the gzipped payload into
ordered sub-cap chunks: it first POSTs ?phase=init declaring the total compressed
size, then POSTs each slice to ?phase=chunk in order with the last flagged
final=true. The server buffers the slices and performs a single UploadThing upload
once the final chunk arrives, storing the same single data_url as the
non-streaming path — the stored artifact is identical.
import { startSession, uploadSessionStreaming } from "@replayio-app-building/session-recorder";
const session = startSession();
const data = session.getSessionData();
const result = await uploadSessionStreaming(session.sessionId, data, {
chunkSize: 3 * 1024 * 1024, // optional; defaults to 3 MB
});Parameters:
sessionId— The session UUIDsessionData— The capturedSimulationDataarrayoptions.endpoint?— Upload URL (default:"/api/upload-session")options.chunkSize?— Bytes per chunk POST; must stay under the platform's request-body cap (default: 3 MB, a safe margin below the measured ceiling where ~4.13 MB passes and ~4.64 MB fails). Larger sessions upload as more chunks — no data is dropped.
Served by the same createUploadSessionHandler endpoint (see Chunked / streaming upload). Requires the session_upload_chunks table.
Types
StartSessionOptions—{ captureSourceMaps?: boolean }(all optional; passed tostartSession())CaptureConfig—{ captureSourceMaps: boolean }(resolved capture config;StartSessionOptionsisPartial<CaptureConfig>)SessionHandle—{ sessionId: string; getSessionData: () => SimulationData; snapshot: () => SessionSnapshot; release?: (count: number) => number }SessionSnapshot—{ sessionId: string; sessionData: SimulationData; releasedCount: number }(fresh id persnapshot()call)SimulationData— Array ofSimulationPacketeventsSimulationPacket— Individual captured DOM event
Server API
Import from @replayio-app-building/session-recorder/server. All handler factories return (req: Request) => Promise<Response> functions suitable for use in Netlify Functions v2, Cloudflare Workers, or any platform with Web API Request/Response.
Each factory accepts a sql parameter — a Neon-style tagged-template SQL function:
type SqlFunction = (
strings: TemplateStringsArray,
...values: unknown[]
) => Promise<unknown[]>;createUploadSessionHandler(options)
Handles POST requests to store captured session data. Accepts JSON or gzip-compressed binary payloads. Compresses data with gzip, uploads it to UploadThing, and stores the resulting single UploadThing URL (https://<appId>.ufs.sh/f/<key>) in the session_recorder_data table. Requires the UPLOADTHING_TOKEN environment variable.
import { createUploadSessionHandler } from "@replayio-app-building/session-recorder/server";
const handler = createUploadSessionHandler({ sql });Options:
sql— SQL functionauthenticate?—(req: Request) => Promise<boolean>— optional auth check
Request body (JSON): { session_id?: string, sessionData: unknown[] }
Request body (binary): gzip-compressed JSON with the same shape
Response: { session_id: string } (201)
Resumable upload to UploadThing
The upload drives UploadThing's documented ranged ingest protocol directly (using generateKey / generateSignedURL from @uploadthing/shared) rather than the higher-level UTApi.uploadFiles helper, which issued a single non-resumable PUT and failed hard on large sessions.
Flow:
- Decode
UPLOADTHING_TOKEN(base64 JSON{ apiKey, appId, regions, ingestHost? }) and build the ingest basehttps://<regions[0]>.<ingestHost ?? "ingest.uploadthing.com">. - Presign
<ingestBase>/<key>, committing the total gzipped size up front via thex-ut-file-sizeheader (required for ranged uploads). PUTthe file in one request (Range: bytes=0-, body is the gzipped blob asmultipart/form-data). The completing PUT returns JSON containingufsUrl, which is stored as thedata_url.- Self-healing resume: on any PUT failure or timeout, the handler issues a
HEADto readx-ut-range-start(the bytes the server has already committed) and re-PUTs only the remaining tail from that offset, retrying up to a bounded number of times. A transient failure that commits partial bytes therefore shrinks the tail on the next attempt instead of failing the whole upload.
Protocol note: the ingest endpoint expects the whole file (or the whole remaining tail on resume) in a single PUT. It does not accept a stream of fixed-size sequential chunks — a second chunk against the same key is rejected with
409 File already exists. The resume mechanism above is the correct way to recover from a partial upload.
The stored output is identical to before (one UploadThing data_url), so createSessionRecordingHandler, the session_recorder_data schema, and any downstream recorder service are unaffected.
Chunked / streaming upload
The same handler also accepts a chunked protocol (driven by the client's
uploadSessionStreaming),
which removes the client→server request-body ceiling. It is selected by a phase
query parameter; without one, the handler uses the backward-compatible single-POST
path above.
POST …/upload-session?phase=init— JSON body{ session_id, total_size }. Clears any stale partial upload for that session and acks.POST …/upload-session?phase=chunk&session_id=…&index=N&offset=…&total_size=…&final=true|false— body is the raw gzipped slice (application/octet-stream, noContent-Encoding). Each slice is buffered (base64) intosession_upload_chunkskeyed by(session_id, chunk_index).
On the chunk flagged final=true, the buffered slices are read in order, concatenated
into the complete gzipped blob, uploaded to UploadThing in a single whole-file
upload (the same uploadToUploadThing path — because the ingest endpoint rejects
incremental sequential PUTs, per the protocol note above), stored as the single
data_url, and the buffer rows are deleted. The 201 response is the same
{ session_id }. Errors map to a 500, leaving the buffered chunks in place so a
re-init + resend can retry.
Requires a session_upload_chunks table (see Database schema).
createSessionRecordingHandler(options)
Handles POST requests to create a Replay recording from a stored session. Updates the session_recorder_data row status and dispatches the work to a background recording process.
When sessionRecorderUrl is provided, delegates recording to an external session recorder service (e.g. this app's deployed instance) via its /api/create-recording and /api/get-session endpoints.
import { createSessionRecordingHandler } from "@replayio-app-building/session-recorder/server";
const handler = createSessionRecordingHandler({
sql,
baseUrl: "https://my-app.netlify.app",
sessionRecorderUrl: "https://session-recorder-si6nol.netlify.app",
pollTimeout: 60000,
});Options:
sql— SQL functionbaseUrl— Base URL of the calling app (used for webhook URLs)authenticate?— Optional auth checkpollTimeout?— If set, the handler polls for completion up to this many milliseconds before returning. Without this, returns immediately with status201.sessionRecorderUrl?— Base URL of an external session recorder service. When set, recording is delegated to that service.
Request body: { session_id: string, check_description?: string }
session_id— The session UUID to create a recording fromcheck_description— Optional description of something that should be visible in both the original rrweb session data and the resulting simulation recording (e.g. a bug the user encountered). When provided, the recording pipeline verifies that the described behavior appears in the rrweb DOM snapshots and in the simulation recording. If the behavior is present in the rrweb data but missing from the simulation, a bug report is filed automatically.
Response: The session_recorder_data row as JSON (201 if returned immediately, 200 if poll completed)
External service protocol
When sessionRecorderUrl is provided, the handler:
- POSTs to
{sessionRecorderUrl}/api/create-recordingwith:{ "sessionId": "<uuid>", "sessionBlobUrl": "{baseUrl}/api/session-data/{sessionId}", "webhookUrl": "{baseUrl}/api/recording-complete?sessionId={sessionId}", "checkDescription": "<optional check_description from the request>" } - If
pollTimeoutis set, polls{sessionRecorderUrl}/api/get-session?sessionId={sessionId}every 2 seconds until the session reaches a terminal status or the timeout expires. - When the recording completes, the service calls the
webhookUrlwith the result.
createRecordingCompleteHandler(options)
Handles POST webhook callbacks when a recording finishes. Updates the session_recorder_data and recording_tasks tables.
import { createRecordingCompleteHandler } from "@replayio-app-building/session-recorder/server";
const handler = createRecordingCompleteHandler({ sql });Options:
sql— SQL functionauthenticate?— Optional auth check
Query parameter: sessionId (required) — the session ID
Request body:
{ status: "recorded", recordingId: "<replay-recording-id>" }— on success{ status: "failed", error: "message" }— on failure
If the session has a caller_webhook_url, the handler forwards the completion payload to that URL via POST (best-effort).
createRecordingsHandler(options)
Handles GET requests to list or retrieve recordings, and DELETE requests to
remove a recording.
import { createRecordingsHandler } from "@replayio-app-building/session-recorder/server";
const handler = createRecordingsHandler({ sql });Options:
sql— SQL functionauthenticate?— Optional auth check (applied to bothGETandDELETE)
Routes:
GET /api/recordings— Lists all recordings (newest first) with data size infoGET /api/recordings/{sessionId}— Returns a single recording by session IDDELETE /api/recordings/{sessionId}— Deletes a recording and every row keyed to the same session (recording logs, recording tasks, and any partial upload chunks). Bug reports are intentionally preserved. Returns{ session_id, deleted: true }, or404if the recording does not exist.
createSessionDataHandler(options)
Handles GET requests to retrieve stored session data for a given session ID. Decompresses gzip data URLs before returning.
import { createSessionDataHandler } from "@replayio-app-building/session-recorder/server";
const handler = createSessionDataHandler({ sql });Options:
sql— SQL functionauthenticate?— Optional auth check
Route: GET /api/session-data/{sessionId}
Response: { session_id, data_url } — where data_url is a base64-encoded JSON data URL
Simulate from a recording
These two factories implement the "recreate a recording from a base recording" feature: given any Replay recording id (the base) plus an optional injected script, the pipeline
- harvests the base recording's session data — read from the session-recorder buffer if this package was running in the original page, otherwise reconstructed from the recording's network requests, clicks, and page state via Replay's time-travel,
- replays that session data to create a new Replay recording, and
- gathers two extra artifacts while doing so — the rrweb DOM event stream and whatever the injected script collected.
createSimulateRecordingHandler owns the HTTP + persistence surface over the recording_simulations table; the harvest and browser simulation run out-of-band (you trigger them from onSimulationCreated) and report their artifacts back through createSimulationCompleteHandler. This keeps the heavy, environment-specific work (reading a recording over the Replay protocol needs a raw Replay API key; running the browser simulation needs a worker) out of the package, while the package guarantees a consistent API and row shape.
POST /api/simulate-recording { recordingId, injectedScript? } -> { id } (201)
GET /api/simulate-recording -> { simulations: [ ...summaries ] }
GET /api/simulate-recording?id=ID -> { simulation: { ...full row incl. artifacts } }
POST /api/simulation-complete?id=ID { status, new_recording_id, rrweb_events, ... } -> { ok: true }createSimulateRecordingHandler(options)
CRUD for recording simulations. A POST inserts a 'pending' row and fires onSimulationCreated, where you kick off your harvest + recording pipeline (e.g. POST to a background function). GET lists summaries (the heavy artifact columns are omitted); GET ?id=ID returns the full row including the stored artifacts.
import { createSimulateRecordingHandler } from "@replayio-app-building/session-recorder/server";
const handler = createSimulateRecordingHandler({
sql,
// Applied to POST (create) only; GET reads are left open.
authenticate: async (req) => (await getUser(req)).authenticated,
onSimulationCreated: async (simulationId, req) => {
const base = new URL(req.url).origin;
await fetch(`${base}/api/reconstruct-session-background`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ simulationId }),
});
},
});Options:
sql— SQL functionauthenticate?— Optional auth check, applied to thePOST(create) path onlyonSimulationCreated?—(simulationId: string, req: Request) => Promise<void> | void. Called after the row is inserted, to start the harvest/recording pipeline. Best-effort: a thrown error is logged and swallowed so a trigger failure never fails the create.
POST body: { recordingId: string, injectedScript?: string }
recordingId— The base Replay recording id to recreate from (required)injectedScript— Optional function body that runs in the recorded page during the new recording. It receives astreamargument (stream.push(type, payload),stream.onFlush(fn)); whatever it emits is stored as the simulation'sinjected_dataartifact.
Responses: { id } (201) on create; { simulations } / { simulation } on GET; 404 for an unknown id; 401 if auth fails; 400 for a missing recordingId or invalid JSON.
createSimulationCompleteHandler(options)
Webhook the simulation worker calls when it finishes. Stores the four artifacts (or the failure) on the recording_simulations row. The body may be plain JSON or a gzipped JSON blob (Content-Encoding: gzip, or an octet-stream / gzip content type) — large rrweb streams compress well, so workers typically gzip it.
import { createSimulationCompleteHandler } from "@replayio-app-building/session-recorder/server";
const handler = createSimulationCompleteHandler({ sql });Options:
sql— SQL functionauthenticate?— Optional auth check
Query parameter: id (required) — the simulation id
Request body (SimulationReport):
status—"complete"(default) or"failed"new_recording_id— id of the freshly created Replay recordingsession_data— the base recording's harvested session data (extracted or reconstructed)rrweb_events— rrweb DOM events captured while creating the new recordinginjected_data— whatever the injected script collected (omitted when no script ran)session_packet_count,rrweb_event_count,injected_event_count— counts shown in list summarieserror_message— failure detail (withstatus: "failed")
Response: { ok: true }
The SimulationReport type is exported from @replayio-app-building/session-recorder/server.
Database Schema
The server handlers expect two tables: session_recorder_data and recording_tasks. Both
are operated on through the sql function you pass in, so both must exist in the consumer's
database — not just in the recording service. Two further tables are needed only for specific
features: session_upload_chunks for the chunked / streaming upload
path, and recording_simulations for the simulate-from-a-recording
handlers.
session_recorder_data
| Column | Type | Description |
|--------|------|-------------|
| session_id | TEXT (PK) | UUID identifying the captured session |
| data_url | TEXT | UploadThing URL pointing to gzip-compressed session data |
| status | TEXT | 'pending', 'queued', 'processing', 'complete', or 'failed' |
| recording_id | TEXT | Replay recording ID (set when complete) |
| error_message | TEXT | Error details (set when failed) |
| check_description | TEXT | Optional description of expected behavior to verify in the recording |
| caller_webhook_url | TEXT | Webhook URL to notify when recording completes (set by external callers) |
| created_at | TIMESTAMPTZ | Row creation time |
| updated_at | TIMESTAMPTZ | Last update time |
recording_tasks
createSessionRecordingHandler inserts a row into this table when a recording is requested —
on both the local-pipeline path and the external-service path (sessionRecorderUrl) — and
createRecordingCompleteHandler updates it from the webhook. It must exist in the consumer's
database; without it, createSessionRecordingHandler fails with
relation "recording_tasks" does not exist.
| Column | Type | Description |
|--------|------|-------------|
| session_id | TEXT (PK) | UUID identifying the captured session |
| data_url | TEXT | UploadThing URL pointing to gzip-compressed session data |
| webhook_url | TEXT | Recording-complete webhook URL for this task |
| status | TEXT | 'pending', 'assigned', or 'failed' |
| created_at | TIMESTAMPTZ | Row creation time |
| updated_at | TIMESTAMPTZ | Last update time |
CREATE TABLE IF NOT EXISTS recording_tasks (
session_id TEXT PRIMARY KEY,
data_url TEXT,
webhook_url TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);session_upload_chunks
Durable buffer for the chunked / streaming upload path.
Each row holds one base64-encoded slice of a gzipped session; on the final chunk the
slices are concatenated, uploaded to UploadThing in one shot, then deleted. Only
required if a client uses uploadSessionStreaming.
| Column | Type | Description |
|--------|------|-------------|
| session_id | TEXT | UUID identifying the captured session (PK part 1) |
| chunk_index | INTEGER | Ordinal position of this slice (PK part 2) |
| data | TEXT | Base64 of the gzipped slice bytes |
| created_at | TIMESTAMPTZ | Row creation time |
CREATE TABLE IF NOT EXISTS session_upload_chunks (
session_id TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
data TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (session_id, chunk_index)
);recording_simulations
Queue + result store for the simulate-from-a-recording feature
(createSimulateRecordingHandler / createSimulationCompleteHandler). Each row is its own
work item: created 'pending', picked up by your worker, then reported back 'complete' /
'failed' with the four artifacts. Only required if you use those handlers.
| Column | Type | Description |
|--------|------|-------------|
| id | UUID (PK) | Simulation id (default gen_random_uuid()) |
| source_recording_id | TEXT | The base Replay recording id to recreate from |
| injected_script | TEXT | Optional function body run in the recorded page |
| status | TEXT | 'pending', 'queued', 'processing', 'complete', or 'failed' |
| new_recording_id | TEXT | The newly created Replay recording id (set when complete) |
| session_data | JSONB | Harvested session data (extracted or reconstructed) |
| rrweb_events | JSONB | rrweb DOM events captured while creating the new recording |
| injected_data | JSONB | Whatever the injected script collected |
| session_packet_count | INTEGER | Count shown in list summaries |
| rrweb_event_count | INTEGER | Count shown in list summaries |
| injected_event_count | INTEGER | Count shown in list summaries |
| error_message | TEXT | Failure detail (set when failed) |
| created_at | TIMESTAMPTZ | Row creation time |
| updated_at | TIMESTAMPTZ | Last update time |
CREATE TABLE IF NOT EXISTS recording_simulations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_recording_id TEXT NOT NULL,
injected_script TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','queued','processing','complete','failed')),
new_recording_id TEXT,
session_data JSONB,
rrweb_events JSONB,
injected_data JSONB,
session_packet_count INTEGER,
rrweb_event_count INTEGER,
injected_event_count INTEGER,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);The consumer may add columns its own pipeline needs (this app, for example, adds a
session_data_urlfor the server-side-harvested blob and anassigned_container_id). The handlers only read/write the columns above.
