Ir al contenido
Consola

Arma tu propio botón de reporte

Reporta bugs desde tu propia interfaz en lugar del botón flotante de Espejo. Apaga el botón, llama a report() desde tu formulario, graba la pantalla con tus propios controles y maneja errores y límites.

Espejo monta por defecto un botón flotante de reporte (un ícono de bug, con la etiqueta “Reportar un problema”; puedes cambiar el texto, ver Opciones del constructor). Es la forma más rápida de empezar, pero tu producto puede tener ya un lugar para esto: un menú de ayuda, un formulario de feedback, una página de soporte. Esta guía muestra cómo apagar nuestro botón y reportar desde tu propia interfaz con el SDK. La grabación es la misma: la red, la consola, los clics y la navegación (más el replay del DOM o un video de la pantalla cuando los usas) viajan con el reporte.

Úsalo cuando:

  • Quieres que el reporte se vea y se lea como el resto de tu producto.
  • Ya tienes un flujo de “reportar un problema” y quieres que la grabación viaje con él.
  • Quieres decidir dónde y cuándo se puede reportar, por ejemplo solo para usuarios con sesión iniciada o solo en algunas páginas.

Instala el paquete. Funciona igual con pnpm o yarn:

Ventana de terminal
npm install @espejo/browser

Crea el grabador una sola vez, en el navegador, y arráncalo. Con button: "off" no se monta el botón flotante de reporte. La captura funciona exactamente igual. Otros widgets de Espejo pueden seguir apareciendo: ver Otros widgets de Espejo.

import { Espejo } from "@espejo/browser";
export const espejo = new Espejo({
key: "pk_live_...",
button: "off",
});
espejo.start();
  • new Espejo() lanza Espejo: missing data-key (the project's public key) sin key.
  • No se graba nada hasta que llamas a start(). Antes de start(), y después de stop(), report() devuelve null.
  • Llama a start() una vez. Una segunda llamada mientras graba no hace nada.
  • Necesita un navegador: créalo del lado del cliente, después de que carga la página, no durante el renderizado en el servidor.

Los tipos de TypeScript vienen incluidos, así que no hay nada más que instalar.

Si no usas un bundler, agrega data-button="off" al script:

<script src="https://app.espejo.dev/sdk/espejo.js"
data-key="pk_live_..."
data-button="off"></script>

El script arranca solo y deja la instancia en window.espejo. Sin data-key no graba nada y no define window.espejo. Cárgalo sin async ni defer si un script inline justo después usa window.espejo, o espera al evento load. Usa espejo.dom.js en lugar de espejo.js para sumar el replay del DOM, como en el Quickstart.

button: "off" solo quita el botón de reporte. Dos piezas más de Espejo pueden seguir apareciendo en tu página, según cómo esté configurado el proyecto en la consola:

  • La encuesta de feedback. Aparece solo si el proyecto la activó. Para suprimirla en una página, pasa feedback: "off" o agrega data-feedback="off". Solo puede apagarla, nunca encenderla. Ver Feedback.
  • Los mensajes en vivo (el banner o el modal de un comportamiento). Aparecen solo si el proyecto configuró un mensaje. No hay una opción en la página para apagarlos: adminístralos en la consola. Ver Comportamientos.
const espejo = new Espejo({
key: "pk_live_...",
button: "off",
feedback: "off",
});
espejo.report(description); // Promise<string | null>
espejo.report(description, undefined, onProgress); // con progreso de subida
  • description: lo que escribió la persona. Viaja con la grabación como texto plano. Un texto vacío se guarda como sin descripción.
  • El segundo argumento: omítelo siempre, o pasa undefined cuando necesites el tercero. Espejo lo usa para sus propios reportes.
  • onProgress: progreso de subida opcional, de 0 a 1. Ver Progreso de subida.

Se resuelve con un texto que identifica el reporte, o con null cuando el grabador no está corriendo. Es para tu equipo, no para la persona que reportó: no se lo muestres. No cuentes con que sirva para abrir la grabación.

Después de un reporte que se resuelve, espejo.lastReportId tiene el id de ese reporte (32 caracteres hexadecimales). Queda en null hasta el primer reporte de la página. Guárdalo junto a tu propio ticket o fila para vincular el reporte con tu propio sistema.

Cada reporte manda lo grabado desde el anterior y después empieza de nuevo, así que dos reportes seguidos no mandan los mismos eventos dos veces. Un reporte automático también cuenta como reporte anterior.

Se rechaza cuando la subida falla. Ver Errores y límites.

Un formulario con su propio campo de texto y su propio botón. Desactiva el botón mientras envía, confirma con un mensaje corto y muestra un error genérico cuando la subida falla.

<form id="report-form">
<label for="report-text">¿Qué salió mal?</label>
<textarea id="report-text" rows="4" required></textarea>
<button id="report-send" type="submit">Enviar reporte</button>
<p id="report-status" role="status"></p>
</form>
import { espejo } from "./espejo"; // la instancia creada arriba
const form = document.querySelector<HTMLFormElement>("#report-form")!;
const text = document.querySelector<HTMLTextAreaElement>("#report-text")!;
const send = document.querySelector<HTMLButtonElement>("#report-send")!;
const status = document.querySelector<HTMLParagraphElement>("#report-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const description = text.value.trim();
if (!description) return;
send.disabled = true;
status.textContent = "Enviando...";
try {
const result = await espejo.report(description);
if (result === null) {
status.textContent = "Ahora no se puede reportar.";
return;
}
text.value = "";
status.textContent = "Gracias. Recibimos tu reporte.";
// Opcional: guarda el id de tu lado, junto a tu propio ticket.
await fetch("/api/support/reports", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ description, id: espejo.lastReportId }),
});
} catch (error) {
// console.warn y no console.error: ver la nota de abajo.
console.warn("Espejo report failed", error);
status.textContent = "No pudimos enviar tu reporte. Prueba de nuevo en un momento.";
} finally {
send.disabled = false;
}
});

La llamada a /api/support/reports es tu propio backend, no el de Espejo. Quítala si no la necesitas.

Registra un reporte fallido con console.warn, no con console.error. Cuando los reportes automáticos están encendidos en el proyecto, un console.error puede disparar uno. console.warn nunca lo hace.

<script src="https://app.espejo.dev/sdk/espejo.js"
data-key="pk_live_..."
data-button="off"></script>
<form id="report-form">
<label for="report-text">¿Qué salió mal?</label>
<textarea id="report-text" rows="4" required></textarea>
<button id="report-send" type="submit">Enviar reporte</button>
<p id="report-status" role="status"></p>
</form>
<script>
const form = document.getElementById("report-form");
const text = document.getElementById("report-text");
const send = document.getElementById("report-send");
const status = document.getElementById("report-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const description = text.value.trim();
if (!description || !window.espejo) return;
send.disabled = true;
status.textContent = "Enviando...";
try {
const result = await window.espejo.report(description);
if (result === null) {
status.textContent = "Ahora no se puede reportar.";
return;
}
text.value = "";
status.textContent = "Gracias. Recibimos tu reporte.";
} catch (error) {
console.warn("Espejo report failed", error);
status.textContent = "No pudimos enviar tu reporte. Prueba de nuevo en un momento.";
} finally {
send.disabled = false;
}
});
</script>

Cuando el agente de soporte abre un ticket, puede adjuntar la última grabación que el visitante reportó en la página. El chat la lee de window.espejo.lastReportId. La etiqueta script define window.espejo por ti. Con npm, defínelo tú para que el chat la encuentre:

(window as any).espejo = espejo;

Un video de la pantalla muestra el bug mejor que cualquier descripción. Puedes ofrecerlo desde tu propia interfaz con estos métodos:

MiembroQué hace
startVideo(options?)Abre el selector de pantalla del navegador y empieza a grabar. Se resuelve cuando la grabación arrancó. No hace nada si ya hay una en curso.
stopVideo()Detiene la grabación y guarda el video para el próximo report(). Se resuelve con el video como Blob, o con null cuando no se estaba grabando.
setMicrophone(on)Enciende o apaga el micrófono sin detener la grabación. Se resuelve con { on, denied }.
discardVideo()Descarta un video ya detenido para que el próximo reporte vaya sin él.
setMarkup(on, onExit?)Enciende o apaga el dibujo sobre la pantalla. Ver Dibujar sobre la pantalla.
recordingVideotrue mientras hay una grabación de pantalla en curso.
microphoneOntrue mientras el micrófono aporta audio.

Desde npm también tienes videoSupported(), que dice si el navegador puede grabar la pantalla, y MAX_VIDEO_SECONDS (240):

import { videoSupported, MAX_VIDEO_SECONDS } from "@espejo/browser";
recordButton.hidden = !videoSupported();

startVideo() y el primer setMicrophone(true) abren un aviso del navegador, y los navegadores solo lo permiten desde un gesto del usuario. Llámalos directamente dentro de un handler de clic. Si se llaman fuera de un clic, por ejemplo desde un temporizador, el navegador los rechaza.

OpciónDefaultSignificado
microphonefalseEmpezar con el micrófono encendido. Puedes cambiarlo después con setMicrophone().
surfacela opción videoSurface, o el selector normalQué ofrece primero el selector: current-tab (la pestaña actual sin selector, en Chrome), browser, window o monitor. Salvo current-tab, es una preferencia: la persona igual puede elegir otra cosa.
onUserStoppedningunoSe llama cuando la persona deja de compartir desde la barra propia del navegador.
onLimitReachedningunoSe llama cuando la grabación llega a su tope de tiempo y se detiene sola.
maxSeconds240Segundos antes de que la grabación se detenga sola. Puedes pedir menos, nunca más de 240.
maxHeight1080Alto máximo de la captura, en píxeles. Las pantallas más grandes se reescalan.
frameRate24Cuadros por segundo.
videoBitsPerSecond600000Bitrate del video. Subirlo llena antes el tope de 25 MiB del video.

startVideo() se rechaza cuando la persona cierra el selector o lo niega, y con This browser can't record the screen. o This browser doesn't support MediaRecorder for webm. cuando el navegador no puede grabar.

Cuando la grabación se detiene sola (la persona usó la barra del navegador, o se llegó al tope de tiempo), recordingVideo sigue en true hasta que llamas a stopVideo() o a report(). Llama a stopVideo() desde onUserStopped y onLimitReached para que tu interfaz salga del estado de grabación.

import { videoSupported } from "@espejo/browser";
import { espejo } from "./espejo";
recordButton.hidden = !videoSupported();
recordButton.addEventListener("click", async () => {
try {
await espejo.startVideo({
onUserStopped: () => void finishRecording(),
onLimitReached: () => void finishRecording(),
});
} catch (error) {
console.warn("Screen recording did not start", error);
return; // la persona cerró el selector, o el navegador no puede grabar
}
showRecordingControls(); // tu interfaz: detener, micrófono, dibujar
});
micButton.addEventListener("click", async () => {
const { on, denied } = await espejo.setMicrophone(!espejo.microphoneOn);
micButton.setAttribute("aria-pressed", String(on));
// denied: el navegador bloquea el micrófono para este sitio. Pedirlo otra vez no muestra nada.
if (denied) micButton.disabled = true;
});
stopButton.addEventListener("click", () => void finishRecording());
async function finishRecording() {
await espejo.setMarkup(false);
const video = await espejo.stopVideo();
showReportForm(video); // el formulario de la sección anterior; el video va con el próximo report()
}
cancelButton.addEventListener("click", async () => {
await espejo.setMarkup(false);
await espejo.stopVideo();
espejo.discardVideo(); // no se envía nada
hideRecordingControls();
});

stopVideo() te da el video como Blob, así que puedes dejar que la persona lo vea antes de enviarlo, por ejemplo con URL.createObjectURL(video).

  • El próximo report() adjunta el video, y después lo descarta.
  • Si todavía hay una grabación en curso, report() la detiene primero y la adjunta.
  • discardVideo() solo descarta un video que ya se detuvo. Para cancelar una grabación en curso, llama antes a stopVideo(), como en el ejemplo.
  • Un video que no envías ni descartas espera al próximo report() de la página. Descártalo cuando la persona cierra tu formulario sin enviar.
  • setMicrophone() no hace nada sin una grabación en curso y se resuelve con { on: false, denied: false }.

Un video puede tardar unos segundos en subir. Pasa onProgress como tercer argumento de report(), con undefined como segundo:

await espejo.report(description, undefined, (fraction) => {
status.textContent = `Enviando... ${Math.round(fraction * 100)}%`;
});

Solo se llama cuando el reporte lleva un video. Tómalo como información extra y no como señal de que la subida sigue viva: algunos navegadores lo llaman una sola vez en 100%, y detrás de algunos proxies no se llama nunca. Muestra “Enviando…” desde el principio y agrega el número cuando llegue.

setMarkup(true, onExit) pone un canvas a pantalla completa sobre la página para que la persona señale el problema mientras graba. Se resuelve con true cuando el dibujo quedó encendido, y con false cuando no pudo arrancar. En ese caso no pasa nada más y la grabación sigue. setMarkup(false) lo apaga y se resuelve con false.

  • Úsalo mientras grabas. El dibujo solo existe en el video de la pantalla: no se guarda en ningún otro lado y no produce una imagen.
  • El canvas cubre toda la página, tu interfaz incluida, así que los clics no llegan a la página mientras está encendido. Espejo muestra su propia barra de herramientas abajo a la derecha: lápiz, rectángulo, cinco colores y deshacer. Los trazos se desvanecen unos segundos después del último movimiento.
  • La persona sale con Escape, que llama a onExit. Para ofrecer tu propio botón de salida, dale position: fixed y z-index: 2147483647 para que quede por encima del canvas, y llama a setMarkup(false) desde él.
  • La primera llamada descarga https://app.espejo.dev/sdk/espejo.draw.js. Si tu sitio tiene una Content Security Policy, permite scripts de https://app.espejo.dev, o setMarkup se resuelve con false.
  • Apágalo antes de detener la grabación, como en el ejemplo: detener el video no quita el canvas.

Pasa un id opaco de tu usuario para que la grabación se pueda rastrear hasta esa persona. Nunca un correo ni un nombre: viaja a la vista en el navegador.

// Al iniciar sesión
espejo.identify("user-123");
// Al cerrar sesión
espejo.identify(null);

También puedes fijarlo desde el principio con la opción userRef o el atributo data-user-ref. Las reglas y dónde se ve están en Identificar al usuario.

release y env etiquetan cada grabación con la versión y el entorno de tu app, para que sepas de qué build vino un bug:

const espejo = new Espejo({
key: "pk_live_...",
button: "off",
release: "2026.09.30",
env: "production",
});

Con la etiqueta script, data-release y data-env.

Las opciones que importan cuando reportas desde tu propia interfaz. Cada una tiene su atributo del script.

OpciónAtributoDefaultSignificado
keydata-keyningunoLa clave pública del proyecto (pk_live_...). Obligatoria.
buttondata-buttononoff no monta el botón flotante de reporte. No afecta la encuesta de feedback ni los mensajes en vivo.
feedbackdata-feedbackonoff suprime la encuesta de feedback en esta página. No puede encenderla: lo decide el proyecto en la consola. Ver Feedback.
capturedata-captureeventsdom suma el replay del DOM. Con la etiqueta script, carga espejo.dom.js en su lugar.
releasedata-releaseningunoLa versión de tu app, visible en la grabación.
envdata-envningunoTu entorno, por ejemplo production o staging.
userRefdata-user-refningunoUn id opaco de tu usuario. Cámbialo después con identify().
videoSurfacedata-video-surfaceel selector normalEl surface por defecto de startVideo(). Un valor fijado para el proyecto en la consola tiene prioridad.
autoReportdata-auto-reportonoff apaga los reportes automáticos en esta página. No puede encenderlos. Ver Reportes automáticos.

buttonText, buttonIcon y position dan estilo al botón de Espejo, así que no hacen nada con button: "off". buttonText es solo una opción del constructor (no tiene atributo del script) y reemplaza los textos campo por campo. Los textos por defecto están en español, por ejemplo:

new Espejo({
key: "pk_live_...",
buttonText: { triggerLabel: "Report a problem", title: "Report a problem" },
});

Otros miembros que te pueden servir:

MiembroQué hace
start()Empieza a grabar.
stop()Deja de capturar y quita el botón de reporte, la encuesta de feedback y los mensajes en vivo. No detiene una grabación de pantalla en curso: llama antes a stopVideo(). Después de stop(), report() devuelve null.
recordingtrue entre start() y stop().
lastReportIdEl id del último reporte que una persona envió en esta página, o null. Los reportes automáticos no lo cambian.
snapshot()Lo grabado hasta ahora, sin subir nada, o null cuando no se está grabando. Sirve para revisar qué mandaría un reporte.

report() se rechaza en estos casos. Captura el error, muestra a la persona un mensaje genérico y registra el detalle con console.warn.

Cuando report() se rechaza no se consume nada: los eventos grabados, el video y lastReportId quedan como estaban. Si reintentas, se envían los mismos eventos y el mismo video, más lo que se grabó entretanto.

MensajeCuándo
ingest rejected the session: HTTP <status> <body>Espejo contestó con un error. El status y los primeros 200 caracteres de la respuesta van en el mensaje.
ingest unreachable: the upload could not be completedLa subida con video y onProgress no pudo llegar a Espejo.
the upload was cancelledLa subida con video y onProgress se canceló.
ingest returned a body that is not JSONLa subida con video y onProgress recibió una respuesta que no es JSON.
El error de red propio del navegadorEl pedido no pudo llegar a Espejo, sin onProgress. El texto depende del navegador.

Los status que puedes recibir:

StatusCausaQué hacer
403Clave ausente, inválida o revocada.Revisa key o data-key.
403El origen de la página no está entre los orígenes permitidos del proyecto. La respuesta dice Origin <origin> is not allowed for this project.Agrega el origen en la consola. Un proyecto sin orígenes permitidos acepta cualquiera.
403El proyecto llegó a su límite diario de sesiones (Daily limit of <n> sessions reached.) o de bytes (Daily byte limit reached.).Los límites se reinician a la medianoche UTC. Los reportes automáticos cuentan para los mismos límites.
403El proyecto está pausado, o la cuenta está desactivada.Revisa el proyecto en la consola.
413La grabación o el video son demasiado grandes.Ver los tamaños más abajo.
503Espejo está recibiendo más de lo que puede sostener en este momento (manda Retry-After), o el almacenamiento del proyecto no está configurado.Pide a la persona que pruebe de nuevo en unos segundos.

Límites:

  • Grabación: hasta 8 MiB por reporte, sin contar el video. El grabador guarda un buffer con tope en memoria y descarta primero los eventos más viejos.
  • Video: hasta 25 MiB y 240 segundos. Con el bitrate por defecto, 240 segundos normalmente entran en ese tamaño. Un videoBitsPerSecond más alto puede pasarse.
  • Límites diarios: sesiones y bytes por proyecto por día, los mismos para los reportes desde tu interfaz, desde nuestro botón y los automáticos.
  • Orígenes permitidos: cuando el proyecto tiene una lista, solo las páginas de esos orígenes pueden reportar.
  • Datos de contacto de quien reporta. No hay un campo para un correo o un nombre. Usa identify() con un id opaco y guarda el contacto de tu lado.
  • Campos propios o metadata. Un reporte lleva su descripción, release, env y userRef, y nada más que elijas tú.
  • Severidad o categoría. No hay un campo para eso. Si lo necesitas, guárdalo en tu propio sistema junto a lastReportId.
  • Adjuntos o capturas de pantalla. Un reporte no puede llevar archivos ni imágenes. El dibujo solo aparece en el video de la pantalla.
  • Source maps. Los stack traces de la grabación se muestran tal como los informó el navegador. El código minificado no se traduce a tus fuentes.
  • Reportar por HTTP. El reporte se hace con el SDK. No hay un contrato HTTP público para subir una grabación desde tu propio cliente.