Skip to content

Core concepts

recorder API (apps/api) storage / consumers
┌──────────────┐ HTTPS POST ┌───────────────────────┐ S3 (R2) ┌─────────────┐
│ <script> │──────────────▶│ /v1/ingest/sessions │───────────▶│ bucket │
│ (packages/ │ x-espejo-key │ IngestKeyGuard │ │ (per-project │
│ browser) │ │ + quota + budget │ │ or shared) │
└──────────────┘ └───────────┬────────────┘ └──────┬──────┘
Chrome extension │ indexes │ signed
(multi-tab, legacy) ▼ │ HMAC links
┌───────────────────────┐ │
│ Postgres: Session, │ │
│ Project, IngestKey │ │
└───────┬───────┬────────┘ │
JWT / admin key │ │ OAuth 2.1 + PKCE │
┌──────────────┘ └───────────────┐ │
▼ ▼ │
┌───────────────────┐ ┌───────────────────┐ │
│ apps/console (SPA)│ │ MCP server (/mcp) │───┘
│ v1/projects, v1/ │ │ 5 read-only tools │
│ sessions │ │ scoped per grant │
└───────────────────┘ └───────────────────┘

Two producers write recordings: the <script> SDK (packages/browser, what these docs cover) and a Chrome extension, which predates the SDK and still exists for the one thing a page script can’t do — following several tabs at once. Both write the same bundle shape, defined once in packages/core so the two consumers (the console and the MCP server) never have to know which one produced a given session.

Everything hangs off a single host (dbuger.dnh.ar in production): the ingest endpoint, the console API, the MCP server and the SDK bundles. One host means the console talks to the API same-origin — no CORS to configure — and one certificate instead of four.

Espejo is multi-tenant. The hierarchy, top to bottom:

  • Tenant — an account. Owns projects and users.
  • Project — one thing being recorded (a site, an app). Has a slug, a storage mode, retention limits, and one or more ingest keys.
  • Ingest key (pk_live_…) — what the <script> tag carries. It is public by design, the same way a Sentry DSN is: it only says which project a recording belongs to, and it grants no read access. Keys can be rotated (issue a new one) or revoked (mark it dead; the row stays, so what came in under it can still be audited).

Two ways to authenticate against the console API: a user session (Authorization: Bearer <jwt>), which is what the console itself sends and what automatically scopes every query to that user’s tenant; or the admin key (x-espejo-admin-key), a machine credential for operating and diagnosing without an account — it sees every tenant, so it’s deliberately kept out of anything that grants access on a person’s behalf (creating a project, approving an MCP connection).

Storage is an adapter with two modes, per project: ours writes to Espejo’s own bucket with credentials from the environment — the default, so a new project can start recording in one click, no cloud credentials form up front. tenant (planned) would write to the customer’s own bucket. Either way, the index — which sessions exist, their metadata — lives in Espejo’s Postgres; without that, listing sessions would mean scanning an entire bucket on every page load.

The client is R2-shaped (region: 'auto', forcePathStyle: true) because that’s what R2 needs; S3 itself works the same way through the same adapter.

A recording is a directory in the bucket:

espejo/<projectId>/<yyyy-mm>/<sessionId>/
manifest.json what this session is and what objects compose it
events.json summary + network + console + interactions + navigation
dom.jsonl.gz DOM replay events (dom mode)
video.webm (video mode)

The path is always built by the server, never proposed by the browser — otherwise a client could overwrite someone else’s session.

events.json opens with a summary on purpose — request count, 4xx/5xx with the click that caused them, console error count — because that’s the first thing anyone reads when diagnosing a bug, human or model:

interface Summary {
request_count: number;
interaction_count: number;
error_4xx_count: number;
error_5xx_count: number;
errors: { t_ms: number | null; method: string | null; url: string | null;
status: number | null;
after: { t_ms: number | null; kind: string; label: string | null } | null }[];
console_error_count: number;
}

followed by network, console, interactions and navigations — the same order the API and the MCP tools return them in.

Redaction runs before truncation — a JSON body cut in half no longer parses, so redacting it afterward is too late. What never gets written:

  • Headers: Authorization, Cookie, Set-Cookie, x-api-key, and any header whose name matches /token|secret/i — defense in depth for headers like x-auth-token that aren’t on the exact list.
  • URLs: query and fragment params matching /token|secret|password|signature|^code$|^key$|credential/i, HTTP basic auth userinfo, and any path segment or bare value shaped like a JWT (three base64url segments joined by dots) — recognized by shape, since a token in a URL path or a hash-routed SPA fragment has no key to flag it by.
  • JSON bodies: any key whose last camelCase/snake_case segment is token, secret, password, credential(s), authorization, jwt, bearer, auth, otp or pwd (plus the two-segment special case api_key/apiKey) — checked by segment, not substring, so author_id and pwd_reset_requested_at survive while access_token doesn’t.
  • Input values: from a form field, only that it changed is recorded — never what was typed.
  • DOM replay: every input is masked, not just password fields. Measured with masking off: a password showed as ******** (rrweb already handles that), but a plain-text credit card field showed in full. A document number or an animal’s tag isn’t less private for not being a password.

Two attributes give an integrator manual control: data-espejo-block cuts a hole in the replay and swallows clicks inside it; data-espejo-mask keeps the shape but hides the text.

Each project carries three independent limits — retention_days, max_recordings, max_video_seconds (accumulated, not per-recording) — and a session is deleted once any of them is exceeded, oldest first. All three checks fail closed: an invalid or missing limit blocks deletion rather than defaulting to “delete everything,” and the planner refuses to run if its candidate list isn’t sorted oldest-to-newest or its totals don’t add up.