Memo records visitor sessions through up to three independent providers:
visits.logrocket_session_url. External replay UI.Each provider is independently toggleable. You can run rrweb-only (zero third-party recording), LogRocket-only (no self-hosted data), all three at once, or none at all — your call.
Every provider has two gates: a boolean env toggle AND the credential being present (rrweb needs no creds).
| Env var | Default | What it does |
|---|---|---|
MEMO_REC_RRWEB | true | Inject the rrweb recorder snippet on view.astro. |
MEMO_REC_LOGROCKET | true if LOGROCKET_APP_ID set | Inject the LogRocket snippet. |
MEMO_REC_CLARITY | true if CLARITY_PROJECT_ID set | Inject the Clarity snippet. |
The credentials still gate the actual injection — set MEMO_REC_LOGROCKET=true but leave LOGROCKET_APP_ID blank and LogRocket stays off. Setting the toggle to false overrides everything; the provider does not fire even if creds exist.
activeRecordingFlags() in src/lib/recording/provider.ts is the single source of truth — both the visitor-view injection and the docs page (Settings → Recording) read from it.
Client-side: when the visitor lands on /<slug>/ AND the rrweb toggle is on, memo injects:
<script>
// Lazy-load https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.4/dist/rrweb.min.js
// record DOM mutations, sample mousemove every 50ms, scroll every 150ms, input "last"
// batch events, POST to /api/recording/rrweb/events every 3s and on pagehide
</script>
The recorder is sampled and capped at 200 events per batch. recordCanvas: false keeps blobs small.
POST /api/recording/rrweb/events accepts { visit, linkId, projectId, events[], final? }. It re-verifies the visitor JWT (cookie must match body.visit), re-checks the link is ok (active + not expired) and that the visitor has actually passed all gates. Then appendRecording(projectId, visitId, events):
recordings/<projectId>/<visitId>.json.gz.memo.visit_recordings with the new event_count and byte_size.The append-on-write strategy works for memo's volume (20–50 visits/mo, a few KB per visit). For higher volumes we'd switch to multi-part keys per batch.
Per-visit replay URL: /u/<username>/projects/<projectId>/activity/<visitId>/replay.
The page mounts rrweb-player against GET /api/owner/recordings/<visitId> (which returns the full event array, ownership-checked). Standard rrweb playback UI: timeline, play/pause, speed control.
r2://<bucket>/recordings/<projectId>/<visitId>.json.gz
One file per visit. Cascades on visits row delete (FK ON DELETE CASCADE). No CDN, no public access — every read goes through the ownership-gated /api/owner/recordings/<visitId> endpoint.
The Replay column on /u/<username>/projects/<id>/activity shows whichever provider(s) recorded the visit:
rrweb link → memo's own replay page.LogRocket link → external LogRocket player.Unchanged from v2. Init snippet injected when MEMO_REC_LOGROCKET is active; LogRocket.getSessionURL() callback POSTs the URL to /api/recording/logrocket which writes it to visits.logrocket_session_url. Activity tab uses it for the LogRocket replay link.
Unchanged from v2. Init snippet injected when MEMO_REC_CLARITY is active. No per-session URL surface — Clarity is project-scoped on their dashboard.
All three providers' origins are whitelisted in the visitor CSP:
script-src 'self' 'unsafe-inline' https://cdn.lr-ingest.io https://www.clarity.ms https://*.clarity.ms https://cdn.jsdelivr.net
connect-src 'self' https://*.lr-ingest.io https://*.clarity.ms
cdn.jsdelivr.net covers rrweb's loader. Owner pages (where the replay player runs) don't apply this CSP — the player runs in the regular owner-side context.
| Tool | Scope | Description |
|---|---|---|
get_replay_url | visits:read | Returns rrweb replay URL (if exists) + LogRocket URL for one visit. |
get_visit_recording_events | visits:read | Returns the raw rrweb event stream (optional limit). |
Claude can pull replays inline and reason about them.
| Concern | Mitigation |
|---|---|
| Hostile origin replaying visit IDs | JWT cookie must match body.visit on every events POST |
| Unauthorised replay download | /api/owner/recordings/<id> joins through projects.owner_id |
| Inflated R2 bills | 60/min per (visit, IP) ratelimit on /api/recording/rrweb/events + 200-event batch cap |
| Recording before gate-pass | nextStep(link, claims) !== "view" short-circuits ingestion |
| Cross-project leak | Body.projectId must match links.project_id for the resolved link |
Space visits (Spaces → Session replay) record exactly like links:
visit_recordings.link_id is nullable; space_id is set for space recordings. The R2 blob stays at recordings/<projectId>/<visitId>.json.gz — scope-agnostic.POST /api/recording/rrweb/events is scope-aware: a scope: "space" batch is validated against the space gate (loadSpace → readSpaceClaims → spaceNextStep === "view") and the server-created visits row must tie the visit to that space + project. The link path is unchanged./u/<username>/projects/<projectId>/activity/<visitId>/replay joins visits → visit_recordings → projects (owner check) — it's link-agnostic, so a space recording (same projectId + visitId) replays with zero changes. The owner reaches it from the space detail page's Recent visits table.