v3 update: memo now ships a self-hosted rrweb path alongside the two third-party providers, controlled by per-provider env booleans. The full v3 design lives at /memo/replay. What follows is the original v2 documentation, kept for context.
Memo records visitor sessions with up to three providers in parallel (v2 was two):
| Provider | Purpose | Per-session deep link | Free tier |
|---|---|---|---|
| LogRocket | Replay URLs surfaced in the owner Activity tab | Yes — captured server-side via LogRocket.getSessionURL() | 1k sessions/mo |
| Microsoft Clarity | Heatmaps and aggregate engagement | No (dashboard-side only) | Unlimited |
Both are loaded only after every gate is passed — never on the gate pages themselves. A visitor who never gets past the email gate never appears in either provider.
src/lib/recording/provider.ts exports buildRecordingSnippet(ctx) which returns an HTML blob containing one or both <script> tags. The view.astro page calls it with:
const recording = buildRecordingSnippet({
visitId: claims.sub,
email: claims.email,
linkId: link.id,
projectId: link.projectId,
});
The blob is appended just before </body> via injectVisitorChrome(..., { extraTail: recording.html }). If neither LOGROCKET_APP_ID nor CLARITY_PROJECT_ID is set, nothing is injected and a one-time console.warn reminds the operator.
<script>
(function() {
var s = document.createElement("script");
s.src = "https://cdn.lr-ingest.io/LogRocket.min.js";
s.async = true;
s.crossOrigin = "anonymous";
s.onload = function () {
if (window.LogRocket) {
window.LogRocket.init('<APP_ID>');
window.LogRocket.identify('<visitId>', {
email: '<email>',
linkId: '<linkId>',
projectId: '<projectId>'
});
window.LogRocket.getSessionURL(function (url) {
navigator.sendBeacon('/api/recording/logrocket', JSON.stringify({
visit: '<visitId>', url: url
}));
});
}
};
document.head.appendChild(s);
})();
</script>
The browser:
cdn.lr-ingest.io (allowed by the visitor CSP).sub so all events on this session cluster correctly.getSessionURL() and beacon-POSTs the resulting URL back to memo.POST /api/recording/logrocket is the only endpoint memo exposes for recording metadata. It:
{ visit: uuid, slug: string, url: url }.https://app.logrocket.com/ (defense against poisoning the visits row).claims.sub === body.visit.UPDATE memo.visits SET logrocket_session_url = $1 WHERE id = $2.The Activity tab reads this column to build the "Replay" link.
The official MS-provided init code, re-emitted server-side with the project id substituted:
<script>
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window,document,"clarity","script","<PROJECT_ID>");
if (window.clarity) {
window.clarity('identify', '<visitId>');
window.clarity('set', 'linkId', '<linkId>');
window.clarity('set', 'projectId', '<projectId>');
}
</script>
Clarity's dashboard uses these tags to filter recordings by link or project. There's no per-session URL to round-trip — Clarity is a project-scoped tool.
We could drop Clarity later, but the cost is approximately zero and the heatmap is the fastest "where did people drop" signal.
The visitor CSP in src/middleware.ts explicitly allows:
script-src 'self' 'unsafe-inline' https://cdn.lr-ingest.io https://www.clarity.ms https://*.clarity.ms
connect-src 'self' https://*.lr-ingest.io https://*.clarity.ms
Owner pages don't include either provider, and the CSP for /u/<username>/* is the stricter frame-ancestors 'self'; base-uri 'self' default.
Both providers see visitor inputs. The watermark, beacon, and these recording scripts are only injected after the visitor consents to whatever the link required — typically email + OTP + (sometimes) NDA. For NDA-protected links the visitor has explicitly agreed to be recorded as part of the agreement body.
LogRocket's sendDefaultPii: false analog here is that we never pass anything beyond visitId, linkId, projectId, and email. No headers, no body data, no Postgres rows.
The provider interface is intentionally narrow — buildRecordingSnippet(ctx) returns one HTML blob, and /api/recording/<vendor> round-trips one URL per session. Adding a third (or replacing LogRocket with self-hosted rrweb) is a contained refactor:
logrocketSnippet + claritySnippet in provider.ts./api/recording/.The v3 roadmap in the project plan calls out a self-hosted rrweb+Clarity option behind the same buildRecordingSnippet contract.
RecordingContext is scope-aware: { visitId, email?, projectId, slug, scope?: "link" | "space", linkId?, spaceId? }. The space viewer injects the recorder with scope: "space" + spaceId (and no linkId); the rrweb/LogRocket/Clarity snippets tolerate a null linkId. The ingest endpoint validates the space gate for space-scoped batches — see Replay → Space scope.