Core concepts
Architecture
Section titled “Architecture” 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.
Tenants, projects and keys
Section titled “Tenants, projects and keys”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: your bucket or ours
Section titled “Storage: your bucket or ours”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.
The bundle
Section titled “The bundle”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
Section titled “Redaction”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 likex-auth-tokenthat 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_casesegment istoken,secret,password,credential(s),authorization,jwt,bearer,auth,otporpwd(plus the two-segment special caseapi_key/apiKey) — checked by segment, not substring, soauthor_idandpwd_reset_requested_atsurvive whileaccess_tokendoesn’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.
Retention
Section titled “Retention”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.
What’s next
Section titled “What’s next”- Quickstart — get a project key and send a recording.
- Console API — the full HTTP surface.
- MCP server — let an agent read recordings directly.
- Tenant integration — how a real product wires Espejo in, using Angirú as the example.