# Install deja session replay

You are a coding agent working in a user's repo. They pasted a prompt asking you to install deja and
gave you a **public key** that looks like `deja_pk_…`. Follow this document exactly.

deja records real user sessions (rrweb) and replays them in a dashboard, plus per-page heatmaps and
friction signals. This document is for someone whose project already exists — you do **not** need a
deja account, a CLI, or an API key. The public key is all you need, and it is safe to commit.

The prompt also gives you a **setup token** (`deja_st_…`). It lets you set this project's page
grouping in Step 4 and can do nothing else — it reads no data and reaches no other project.
**Never write either value into a committed file other than the tracker snippet** (the public key
belongs there; the setup token belongs nowhere on disk — use it from the shell and forget it).

**If you were not given a `deja_pk_…` key, stop and ask for it.** Both values are on the project's
Settings screen in the deja dashboard.

## What you are installing (say this to the user if they ask)

- **~30 KB gzipped**, fetched from `https://clever-mole-378.convex.site/tracker.js`.
- **Never on the critical path.** It loads only after the page is interactive — gated behind first
  interaction and browser idle time — so it does not compete with the page's own rendering.
- **Their backend is not involved.** Events go to deja, not to the user's servers. No new
  dependency, no new service to run, no server-side change of any kind.
- **Bounded memory.** Events buffer in the browser and flush every 10 seconds or at 256 KB,
  whichever comes first.
- **Not video.** rrweb takes one DOM snapshot, then records the changes to it. Replay re-renders the
  real page from that stream, which is why a session costs kilobytes rather than megabytes.

## Step 1 — install the loader

Never add the tracker as a plain `<script>` in `<head>`. Even `async` scripts cost a request and
parse time while the page is still loading. Use the gated loader below, which does two things: it
installs a queue stub (so `deja("identify", …)` is safe to call at any time), and it injects the
real script only after the user interacts or the browser goes idle.

Pick the recipe matching the project. Replace `PK_HERE` with the user's `deja_pk_…` key.

### Next.js (App Router)

Create `components/Deja.tsx` — a **server** component (no `"use client"`):

```tsx
import Script from "next/script";

const DEJA_KEY = "PK_HERE";

// Queue stub as a plain inline script, not <Script strategy="beforeInteractive">:
// beforeInteractive is root-layout-only, and a server-rendered inline script
// already runs before hydration with no network cost.
const DEJA_STUB = `window.deja=window.deja||function(){(window.deja.q=window.deja.q||[]).push(arguments)};`;

export function Deja() {
  return (
    <>
      <script dangerouslySetInnerHTML={{ __html: DEJA_STUB }} />
      <Script
        src="https://clever-mole-378.convex.site/tracker.js"
        data-deja-key={DEJA_KEY}
        strategy="lazyOnload"
      />
    </>
  );
}
```

Render `<Deja />` near the end of `<body>` in `app/layout.tsx`.

### Astro

Create `src/components/Deja.astro`, then `<Deja />` at the end of `<body>` in the base layout:

```astro
---
const DEJA_KEY = "PK_HERE";
const IS_PROD = import.meta.env.PROD; // drop if the site has no prod/dev split
---
{IS_PROD && DEJA_KEY && (
  <script define:vars={{ key: DEJA_KEY }} is:inline>
    (function (key) {
      window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
      const events = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];
      let injected = false;
      function inject() {
        if (injected) return;
        injected = true;
        const s = document.createElement("script");
        s.async = true;
        s.src = "https://clever-mole-378.convex.site/tracker.js";
        s.dataset.dejaKey = key;
        document.body.appendChild(s);
      }
      events.forEach((ev) => window.addEventListener(ev, inject, { once: true, passive: true }));
      window.addEventListener("load", () => {
        if ("requestIdleCallback" in window) requestIdleCallback(inject, { timeout: 4000 });
        else setTimeout(inject, 4000);
      });
    })(key);
  </script>
)}
```

### Plain HTML, Vite, or any other framework

Put this immediately before `</body>` (in a Vite/React SPA, put it in `index.html`, not in a
component — it must not re-run on every render):

```html
<script>
  (function (key) {
    window.deja = window.deja || function () { (window.deja.q = window.deja.q || []).push(arguments); };
    var events = ["pointerdown", "keydown", "touchstart", "scroll", "mousemove"];
    var injected = false;
    function inject() {
      if (injected) return;
      injected = true;
      var s = document.createElement("script");
      s.async = true;
      s.src = "https://clever-mole-378.convex.site/tracker.js";
      s.dataset.dejaKey = key;
      document.body.appendChild(s);
    }
    events.forEach(function (ev) {
      window.addEventListener(ev, inject, { once: true, passive: true });
    });
    window.addEventListener("load", function () {
      if ("requestIdleCallback" in window) requestIdleCallback(inject, { timeout: 4000 });
      else setTimeout(inject, 4000);
    });
  })("PK_HERE");
</script>
```

Client-side route changes are handled automatically — the tracker patches `history.pushState` /
`replaceState` itself, so a single install covers every route in an SPA.

## Step 2 — protect sensitive fields

deja **records the content typed into ordinary form fields**, so replays show what users actually
did. Two things are true, and the rest is the site owner's call:

- **Passwords are never recorded.** Any `input[type=password]` is masked automatically.
- **Anything else must be marked.** Add one of these to the element:
  - `class="deja-mask"` / `data-deja-mask` — value is starred, field stays visible.
  - `class="deja-block"` / `data-deja-block` — element omitted entirely from the recording.
  - `class="deja-ignore"` — input events on the field are not recorded.

**Scan the repo for fields holding personal or financial data** — national id numbers, card numbers,
health information, addresses — and mark them. Then tell the user, in plain words, that typed form
content is recorded and that they should mention session recording in their privacy policy.

## Step 3 — identify signed-in users (skip if there is no sign-in)

Without this every session is anonymous: the dashboard's **People** screen shows only
`Anonymous · a4f21c` rows. If the project has authentication anywhere — including a marketing site
with a logged-in area — call this wherever the current user is already known, on every page load,
not only at the moment of login:

```js
deja("identify", user.id, { email: user.email, name: user.name, accountId: org?.id });
```

- `user.id` must be the app's own **stable** user id, not an email — it is the key that joins every
  session that person records.
- `accountId` is the org/workspace/tenant id, if the product has one. It is what groups several
  people's sessions under one customer.
- On sign-out call `deja("reset")`. It closes out the session and rotates the anonymous id, so the
  next person on a shared machine is not attributed to the previous user.

Both calls are safe before the tracker has loaded, thanks to the queue stub from Step 1.

## Step 4 — group the screens (do this before any real traffic)

deja groups sessions by *page*, and works out what a page is from the URL. Segments that **look**
opaque collapse on their own:

| URL segment | Becomes | Handled automatically? |
| --- | --- | --- |
| `/orders/1042` | `/orders/:id` | yes — digits |
| `/u/8b0f2c1e-…-a91d` | `/u/:uuid` | yes — uuid |
| `/r/f3a9c72b41d8` | `/r/:hash` | yes — ≥12 lowercase alphanumerics, no word structure |
| `/reports/2024-01-15` | `/reports/:date` | yes |
| `/acme/billing` | **`/acme/billing`** | **NO — a tenant slug looks exactly like content** |
| `/users/user_3F0CV…` | **`/users/user_3F0CV…`** | **NO — the `_` and mixed case fail the hash test** |

The last two rows are the problem. A slug is left alone on purpose, because collapsing
`/blog/my-post` would ruin the Pages list for a content site. But in a multi-tenant app the tenant
slug is a *value*, and leaving it in turns one screen into one row per customer. Stripe/Clerk-style
prefixed ids (`user_…`, `cus_…`, `acct_…`) do the same, one row per record.

**So: read the project's router** (`app/`, `pages/`, a route table, whatever it uses) and look for
route segments that are neither literal words nor already covered by the table above. If you find
none — every URL is a fixed path, or only carries numeric/uuid ids — say so and skip to Step 5.

If you find some, write one rule per route shape. `{name}` matches a single segment and is displayed
by name; a trailing `*` matches any deeper path and hands the rest back to the automatic rules.

```
/settings/*
/{organization}/{project}/users/{user}
/{organization}/{project}
/{organization}/{project}/*
```

**Order is the thing to get right.** First match wins, and a bare `/{a}/{b}/*` matches *every* path
with two or more segments — so any route that is NOT tenant-scoped has to be listed above it. A
literal prefix rule like `/settings/*` is how you protect one.

**Apply them yourself** with the setup token — do not ask the user to type anything into a
settings screen:

```bash
curl -sS -X POST https://clever-mole-378.convex.site/api/setup/path-rules \
  -H "Authorization: Bearer deja_st_YOUR_SETUP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"rules":["/settings/*","/{organization}/{project}","/{organization}/{project}/*"]}'
```

A success looks like `{"ok":true,"pathRules":[…],"backfillNeeded":false}`. Read the response:

- **`400` with `invalid`** — those patterns did not compile. Check the `{name}` braces and the
  trailing `*`, fix them, and send the whole list again. Nothing was saved, so the project still has
  whatever it had before; you are not half-applied.
- **`401`** — the token is wrong or expired (they last 30 days). Ask the user to re-copy the prompt
  from the project's Settings screen, which mints a fresh one.
- **`"backfillNeeded": true`** — the project already had pages under the old grouping. Tell the user
  to run **Settings → Analysis → Backfill** so the existing recordings are re-keyed. On a new
  install this is `false` and there is nothing to say.

To see what a project currently has before you change it:

```bash
curl -sS -X POST https://clever-mole-378.convex.site/api/setup/whoami \
  -H "Authorization: Bearer deja_st_YOUR_SETUP_TOKEN"
```

**Do this before the site sees real traffic.** Page identity is decided the moment the first session
lands; rules applied later only re-key existing pages after a purge + backfill, which on a busy
project means re-reading every stored recording.

## Step 5 — verify, then report back

1. Run the site and open it in a browser.
2. Click and scroll — the loader is deliberately gated, so an untouched page records nothing.
3. In devtools → Network, confirm a request to `tracker.js` (200) and, within ~10 seconds, a request
   to `ingest?pk=…` (200). If `tracker.js` 404s, the key or URL is wrong; if it never fires, the
   loader was placed somewhere that does not execute.
4. Tell the user to check the deja dashboard — the session appears within a few seconds of the first
   `ingest` request, and the **Pages** screen fills in as they browse.
5. Open **Pages** with them and look for the same screen repeated with a different id or slug in it.
   That is a missing Step 4 rule, and it is much cheaper to fix now than after a week of traffic.

Report what you changed, which files, the path rules you applied (Step 4) and why, and remind them
about the privacy policy from Step 2.
