Ir al contenido

Conceptos fundamentales

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 │
└───────────────────┘ └───────────────────┘

Hay dos productores de grabaciones: el SDK <script> (packages/browser, que es lo que cubre esta documentación) y una extensión de Chrome, que es anterior al SDK y sigue existiendo para lo único que un script de página no puede hacer: seguir varias pestañas a la vez. Ambos escriben la misma forma de bundle, definida una sola vez en packages/core para que los dos consumidores (la consola y el servidor MCP) nunca necesiten saber cuál produjo una sesión determinada.

Todo cuelga de un único host (dbuger.dnh.ar en producción): el endpoint de ingesta, la API de la consola, el servidor MCP y los bundles del SDK. Un solo host significa que la consola habla con la API en el mismo origen — sin CORS que configurar — y un único certificado en lugar de cuatro.

Espejo es multi-tenant. La jerarquía, de arriba hacia abajo:

  • Tenant — una cuenta. Es dueña de proyectos y usuarios.
  • Proyecto — una cosa que se está grabando (un sitio, una app). Tiene un slug, un modo de almacenamiento, límites de retención y una o más claves de ingesta.
  • Clave de ingesta (pk_live_…) — lo que lleva el tag <script>. Es pública por diseño, de la misma manera que un DSN de Sentry: solo indica a qué proyecto pertenece una grabación y no otorga acceso de lectura. Las claves se pueden rotar (emitir una nueva) o revocar (marcarla como inactiva; la fila se conserva para que lo que llegó bajo ella pueda auditarse).

Hay dos formas de autenticarse contra la API de la consola: una sesión de usuario (Authorization: Bearer <jwt>), que es lo que envía la propia consola y que automáticamente acota cada consulta al tenant de ese usuario; o la clave de administrador (x-espejo-admin-key), una credencial de máquina para operar y diagnosticar sin una cuenta — ve todos los tenants, por lo que se mantiene deliberadamente fuera de cualquier cosa que otorgue acceso en nombre de una persona (crear un proyecto, aprobar una conexión MCP).

El almacenamiento es un adaptador con dos modos, por proyecto: ours escribe en el bucket propio de Espejo con credenciales del entorno — el valor por defecto, para que un proyecto nuevo pueda empezar a grabar con un clic, sin formulario de credenciales cloud por adelantado. tenant (planificado) escribiría en el bucket propio del cliente. De cualquier manera, el índice — qué sesiones existen, sus metadatos — vive en el Postgres de Espejo; sin eso, listar sesiones implicaría escanear un bucket entero en cada carga de página.

El cliente tiene forma R2 (region: 'auto', forcePathStyle: true) porque eso es lo que necesita R2; el propio S3 funciona igual a través del mismo adaptador.

Una grabación es un directorio en el 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)

La ruta siempre la construye el servidor, nunca la propone el navegador — de lo contrario, un cliente podría sobreescribir la sesión de otra persona.

events.json abre con un summary a propósito — cantidad de requests, 4xx/5xx con el clic que los causó, cantidad de errores de consola — porque eso es lo primero que lee cualquiera al diagnosticar un bug, sea humano o modelo:

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;
}

seguido de network, console, interactions y navigations — el mismo orden en que los devuelven la API y las herramientas MCP.

La redacción corre antes del truncado — un cuerpo JSON cortado a la mitad ya no parsea, así que redactarlo después es demasiado tarde. Lo que nunca se escribe:

  • Headers: Authorization, Cookie, Set-Cookie, x-api-key, y cualquier header cuyo nombre coincida con /token|secret/i — defensa en profundidad para headers como x-auth-token que no están en la lista exacta.
  • URLs: parámetros de query y fragmento que coincidan con /token|secret|password|signature|^code$|^key$|credential/i, userinfo de autenticación básica HTTP, y cualquier segmento de ruta o valor suelto con forma de JWT (tres segmentos base64url unidos por puntos) — reconocido por forma, ya que un token en una ruta URL o en un fragmento de SPA con hash routing no tiene clave que lo identifique.
  • Cuerpos JSON: cualquier clave cuyo último segmento camelCase/snake_case sea token, secret, password, credential(s), authorization, jwt, bearer, auth, otp o pwd (más el caso especial de dos segmentos api_key/apiKey) — verificado por segmento, no por subcadena, así author_id y pwd_reset_requested_at sobreviven mientras que access_token no.
  • Valores de inputs: de un campo de formulario, solo se registra que cambió — nunca lo que se escribió.
  • Replay del DOM: todos los inputs se enmascaran, no solo los campos de contraseña. Medido con el enmascaramiento desactivado: una contraseña aparecía como ******** (rrweb ya maneja eso), pero un campo de tarjeta de crédito en texto plano aparecía completo. Un número de documento o el tag de un animal no es menos privado por no ser una contraseña.

Dos atributos le dan control manual a quien integra: data-espejo-block abre un hueco en el replay y descarta los clics dentro de él; data-espejo-mask conserva la forma pero oculta el texto.

Cada proyecto tiene tres límites independientes — retention_days, max_recordings, max_video_seconds (acumulado, no por grabación) — y una sesión se elimina en cuanto se supera cualquiera de ellos, empezando por la más antigua. Las tres verificaciones fallan de forma cerrada: un límite inválido o ausente bloquea la eliminación en lugar de defaultear a “borrar todo”, y el planificador se niega a correr si su lista de candidatos no está ordenada de más antigua a más nueva o si sus totales no cuadran.