# Añade un formulario de reporte de bugs que capture la página, el navegador y los pasos

> Un formulario de reporte de bugs que pide a la gente los pasos y la gravedad, y deja que la página añada por su cuenta la URL, el navegador, el viewport y los errores recientes.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/feedback-form/bug-report

Pide a quien reporta solo lo que sabe: qué falló, qué hizo antes y cuánto le bloquea. Deja que la página rellene el resto al enviar: la URL de la página, el user agent del navegador, el viewport y los últimos errores de JavaScript. Envíalo a un backend de formularios y reenvía cada reporte a tu gestor de incidencias por webhook.

## Qué tiene de distinto un reporte de bugs

Un reporte de bugs solo sirve si puedes reproducirlo, y quien lo reporta rara vez conoce los detalles que necesitas. Pregunta a alguien qué navegador usa y te dirá "Chrome" o "el normal". Pide la URL y te dará el nombre de la página. Así que divide el formulario en dos: preguntas que solo quien reporta puede responder, y contexto que la página ya conoce.

Diagrama: Qué partes de un reporte de bugs escribe la persona y cuáles añade la página

1. Quien reporta escribe un resumen de una línea.
2. Describe lo que hizo justo antes de que fallara.
3. Elige cuánto le bloquea.
4. Puede pegar un enlace a una captura o una grabación.
5. Al enviar, la página añade su propia dirección sin la query string.
6. Añade el user agent del navegador.
7. Añade el tamaño de la ventana y el pixel ratio.
8. Añade los últimos errores de JavaScript que vio, sin los nombres de host.

La fila de arriba es todo lo que ve quien reporta. La de abajo son inputs ocultos, que se rellenan en cuanto pulsa Enviar.

Tres restricciones dan forma al código:

- **El referrer no es la página.** Por defecto, los navegadores solo envían tu origen en un envío entre sitios, así que `meta.referrer` no te dirá la ruta. Envía `page_url` tú mismo.
- **Un envío desde el servidor oculta el navegador.** Ikiform registra el user agent de quien hace la petición. Desde una server action, ese es tu servidor. Reenvía la cabecera `user-agent` del visitante como un campo.
- **Las trazas de pila están llenas de enlaces.** Cada frame tiene la URL de un script, y tres o más enlaces suman 2 a la puntuación de spam. Quita los hosts antes de enviar los errores, o un reporte con un enlace a una captura puede acabar en spam.

## Los campos

Formulario: Bug report

- **¿Qué falló?** (`summary`, text, obligatorio)
- **¿Qué hiciste justo antes?** (`steps`, textarea, obligatorio)
- **¿Qué esperabas que pasara?** (`expected`, textarea)
- **¿Cuánto te bloquea?** (`severity`, radio, obligatorio)
- **Enlace a una captura o grabación** (`screenshot_url`, url)
- **Email para preguntas de seguimiento** (`email`, email)
- **Página** (`page_url`, url)
- **Navegador** (`user_agent`, text)
- **Viewport** (`viewport`, text)
- **Versión de la app** (`app_version`, text)
- **Errores recientes** (`recent_errors`, textarea)

Los cinco últimos campos son inputs ocultos en el formulario real. Al definirlos, Ikiform comprueba su longitud y tipo como con cualquier otro campo.

La gravedad se formula como lo que le provoca a quien reporta, no como una etiqueta de prioridad. La gente elige "No puedo continuar" con precisión; en cambio, elige "Crítico" para todo. Los pasos tienen un mínimo de 20 caracteres, lo que convierte "está roto" en una frase con la que puedes trabajar.

## Captura el contexto

El navegador rellena los campos ocultos en el handler de `submit`, cuando la persona ya ha terminado de escribir, así que la lista de errores incluye cualquier cosa que fallara mientras escribía el reporte.

**HTML**

El formulario se envía de la forma normal; el script solo rellena los inputs ocultos durante el `submit`, antes de que el navegador construya la petición. Pon `data-app-version` en el elemento `<html>` en tiempo de build. El navegador envía directamente, así que el `meta.user_agent` propio de Ikiform coincide con el campo `user_agent`.

`report-bug.html`:

```html
<form id="bug" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="summary">What went wrong?</label>
  <input id="summary" name="summary" required minlength="8" maxlength="140" />
  <label for="steps">What did you do right before?</label>
  <textarea id="steps" name="steps" rows="5" required minlength="20"></textarea>
  <label for="expected">What did you expect to happen?</label>
  <textarea id="expected" name="expected" rows="3"></textarea>
  <fieldset>
    <legend>How much does it block you?</legend>
    <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
    <label><input type="radio" name="severity" value="major" /> I found a workaround</label>
    <label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
  </fieldset>
  <label for="screenshot_url">Link to a screenshot or recording</label>
  <input id="screenshot_url" name="screenshot_url" type="url" />
  <label for="email">Email for follow-up questions</label>
  <input id="email" name="email" type="email" />
  <input type="hidden" name="page_url" />
  <input type="hidden" name="user_agent" />
  <input type="hidden" name="viewport" />
  <input type="hidden" name="app_version" />
  <input type="hidden" name="recent_errors" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send report</button>
</form>

<script>
  const recent = [];
  function remember(text) {
    recent.push(String(text).replace(/https?:\/\/[^/\s]+/g, "").slice(0, 400));
    if (recent.length > 5) recent.shift();
  }
  window.addEventListener("error", (event) => remember(`${event.message} at ${event.filename}:${event.lineno}`));
  window.addEventListener("unhandledrejection", (event) => remember(`Unhandled: ${event.reason?.stack ?? event.reason}`));

  document.getElementById("bug").addEventListener("submit", (event) => {
    const fields = event.currentTarget.elements;
    fields.page_url.value = location.origin + location.pathname;
    fields.user_agent.value = navigator.userAgent;
    fields.viewport.value = `${innerWidth}x${innerHeight} @${devicePixelRatio}x`;
    fields.app_version.value = document.documentElement.dataset.appVersion ?? "";
    fields.recent_errors.value = recent.join("\n---\n");
  });
</script>
```

**Next.js**

Un pequeño módulo guarda en memoria los últimos cinco errores y devuelve el contexto del navegador. El formulario envuelve la action para que el contexto se añada al `FormData` al enviar, y la server action añade el user agent del visitante desde `headers()`.

`lib/error-buffer.ts`:

```ts
const recent: string[] = [];

function remember(text: string) {
  recent.push(text.replace(/https?:\/\/[^/\s]+/g, "").slice(0, 400));
  if (recent.length > 5) recent.shift();
}

export function installErrorBuffer() {
  const onError = (event: ErrorEvent) => remember(`${event.message} at ${event.filename}:${event.lineno}`);
  const onRejection = (event: PromiseRejectionEvent) => remember(`Unhandled: ${event.reason?.stack ?? event.reason}`);
  window.addEventListener("error", onError);
  window.addEventListener("unhandledrejection", onRejection);
  return () => {
    window.removeEventListener("error", onError);
    window.removeEventListener("unhandledrejection", onRejection);
  };
}

export function browserContext() {
  return {
    page_url: location.origin + location.pathname,
    viewport: `${innerWidth}x${innerHeight} @${devicePixelRatio}x`,
    recent_errors: recent.join("\n---\n"),
  };
}
```

`app/report/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

const FIELDS = ["summary", "steps", "expected", "severity", "screenshot_url", "email", "page_url", "viewport", "recent_errors", "_gotcha"];

export interface ReportState {
  reference: string;
  error: string;
}

export async function reportBug(_previous: ReportState, formData: FormData): Promise<ReportState> {
  const fields = Object.fromEntries(FIELDS.map((name) => [name, String(formData.get(name) ?? "")]));
  const incoming = await headers();
  const origin = incoming.get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...fields,
      user_agent: incoming.get("user-agent") ?? "",
      app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { reference: result.id, error: "" };
  const errors: Record<string, string[]> = result?.error?.fields ?? {};
  const first = Object.entries(errors)[0];
  return { reference: "", error: first ? `${first[0]}: ${first[1][0]}` : "Couldn't send the report. Try again." };
}
```

`app/report/bug-report-form.tsx`:

```tsx
"use client";

import { useActionState, useEffect } from "react";
import { browserContext, installErrorBuffer } from "@/lib/error-buffer";
import { type ReportState, reportBug } from "./actions";

const initial: ReportState = { reference: "", error: "" };

export function BugReportForm() {
  const [state, dispatch, pending] = useActionState(reportBug, initial);
  useEffect(() => installErrorBuffer(), []);

  function withContext(formData: FormData) {
    for (const [name, value] of Object.entries(browserContext())) formData.set(name, value);
    dispatch(formData);
  }

  if (state.reference) return <p role="status">Thanks. Quote {state.reference} if you write to us about it.</p>;

  return (
    <form action={withContext}>
      <label htmlFor="summary">What went wrong?</label>
      <input id="summary" name="summary" required minLength={8} maxLength={140} />
      <label htmlFor="steps">What did you do right before?</label>
      <textarea id="steps" name="steps" rows={5} required minLength={20} />
      <label htmlFor="expected">What did you expect to happen?</label>
      <textarea id="expected" name="expected" rows={3} />
      <fieldset>
        <legend>How much does it block you?</legend>
        <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
        <label><input type="radio" name="severity" value="major" /> I found a workaround</label>
        <label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
      </fieldset>
      <label htmlFor="screenshot_url">Link to a screenshot or recording</label>
      <input id="screenshot_url" name="screenshot_url" type="url" />
      <label htmlFor="email">Email for follow-up questions</label>
      <input id="email" name="email" type="email" />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.error ? <p role="alert">{state.error}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
    </form>
  );
}
```

**SvelteKit**

El mismo módulo `error-buffer.ts` va en `src/lib`. `use:enhance` te da el `FormData` antes de enviarlo, así que el contexto se añade ahí. La action lee el user agent de la petición y la versión de una variable de entorno pública. Declara ambas en `src/env.ts` con `defineEnvVars`, `PUBLIC_APP_VERSION` con `public: true`, para que `$app/env/private` y `$app/env/public` puedan leerlas.

`src/routes/report/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { PUBLIC_APP_VERSION } from "$app/env/public";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const data = await request.formData();
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({
        ...Object.fromEntries(data),
        user_agent: request.headers.get("user-agent") ?? "",
        app_version: PUBLIC_APP_VERSION,
      }),
    });
    const result = await response.json();
    if (!result.ok) return fail(response.status, { errors: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) });
    return { reference: String(result.id) };
  },
} satisfies Actions;
```

`src/routes/report/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { browserContext, installErrorBuffer } from "#lib/error-buffer.js";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  $effect(() => installErrorBuffer());
</script>

{#if form?.reference}
  <p role="status">Thanks. Quote {form.reference} if you write to us about it.</p>
{:else}
  <form
    method="POST"
    use:enhance={({ formData }) => {
      for (const [name, value] of Object.entries(browserContext())) formData.set(name, value);
    }}
  >
    <label for="summary">What went wrong?</label>
    <input id="summary" name="summary" required minlength="8" maxlength="140" />
    <label for="steps">What did you do right before?</label>
    <textarea id="steps" name="steps" rows="5" required minlength="20"></textarea>
    <label for="expected">What did you expect to happen?</label>
    <textarea id="expected" name="expected" rows="3"></textarea>
    <fieldset>
      <legend>How much does it block you?</legend>
      <label><input type="radio" name="severity" value="blocker" required /> I can't continue</label>
      <label><input type="radio" name="severity" value="major" /> I found a workaround</label>
      <label><input type="radio" name="severity" value="minor" /> It's cosmetic</label>
    </fieldset>
    <label for="screenshot_url">Link to a screenshot or recording</label>
    <input id="screenshot_url" name="screenshot_url" type="url" />
    <label for="email">Email for follow-up questions</label>
    <input id="email" name="email" type="email" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{Object.values(form.errors)[0]?.[0] ?? form.message}</p>
    {/if}
    <button>Send report</button>
  </form>
{/if}
```

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

El mensaje de éxito muestra el id del envío. Cuando quien reportó escriba más tarde a soporte, ese id encuentra el reporte en una sola búsqueda. La [plantilla de reporte de bugs](https://ikiform.com/forms/bug-report) tiene una versión más corta sin el contexto capturado.

## A tu gestor de incidencias

Los reportes deben estar donde se hará el seguimiento del arreglo. Añade un webhook que apunte a un relay que cree una incidencia: `summary` como título, los pasos y el contexto como cuerpo, `severity` como etiqueta. La [guía de Linear y GitHub Issues](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) tiene ese relay, con comprobación de firmas y reintentos. La [documentación de webhooks](https://ikiform.com/es/docs/webhooks.md) lista cada valor de `meta` que lleva el payload. Pon los campos capturados en un bloque plegado al final del cuerpo de la incidencia para que los pasos se lean bien.

| Campo | En la incidencia |
| --- | --- |
| `summary` | Título |
| `steps`, `expected` | Cuerpo, al principio |
| `severity` | Una etiqueta, para poder filtrar los bloqueantes |
| `page_url`, `user_agent`, `viewport`, `app_version` | Un bloque de detalles bajo los pasos |
| `recent_errors` | Un bloque de código, al final |

## Comprueba que funciona

1. Abre la página en un navegador de escritorio, ejecuta `undefinedFunction()` en la consola y luego envía un reporte.
2. Abre el envío en el panel. `recent_errors` contiene el `ReferenceError` sin ningún host `https://`.
3. `user_agent` muestra tu navegador, no `node`, también en las versiones de servidor.
4. `page_url` tiene la ruta pero no la query string.
5. Envía un reporte con un `steps` de 10 caracteres. Ikiform responde `422` nombrando `steps`, y el formulario muestra el mensaje.

## Errores comunes

- La subida de archivos aún no se admite. Un envío con un archivo recibe `422 files_not_supported`. Pide un enlace a una captura, o sube antes la imagen a tu propio almacenamiento y envía su URL.
- Cada valor puede ocupar hasta 16 KB. Cinco errores recortados a 400 caracteres quedan muy por debajo; un volcado completo de la consola no.
- Los navegadores Chromium congelan partes del user agent, como la versión de macOS, que siempre aparece como `10_15_7`. Al hacer el triaje, usa el nombre del navegador y su versión principal, no la versión del sistema operativo.
- Los errores de scripts de otro origen solo muestran `Script error.` salvo que ese script se sirva con cabeceras CORS y se cargue con `crossorigin`.
- No captures en el reporte el contenido de formularios, el local storage ni las cookies. Pueden contener contraseñas y tokens, y los guardarías en texto plano.

## Solución de problemas

| Problema | Qué hacer |
| --- | --- |
| `422 validation_failed` | El envío no coincide con la definición de campos del formulario. `error.fields` indica cada campo y el motivo. Haz que el `name` del input coincida con el id del campo. |
| `422 files_not_supported` | El formulario tiene un `<input type="file">` con un archivo elegido. La subida de archivos aún no está disponible; quita el input o pide un enlace. |
| Los reportes con enlaces a capturas acaban en spam | Cuenta los enlaces de todos los campos. Tres o más suman a la puntuación; con una repetición del mismo reporte se alcanza el umbral. Quita los hosts de los errores y marca el reporte como no spam. |
| `403 origin_not_allowed`, o todos los envíos acaban en spam | El dominio de la página no está en **Allowed domains** del formulario. Añádelo, y `localhost` para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |

## Preguntas

### ¿Cómo capturo el navegador y el sistema operativo en un formulario de reporte de bugs?

Lee navigator.userAgent en el handler de submit y envíalo como un campo. Cuando el formulario envía directamente desde el navegador, Ikiform registra el mismo valor como meta.user_agent; cuando envía desde tu servidor, reenvía la cabecera user-agent del visitante.

### ¿Puedo incluir errores de la consola en un reporte de bugs?

Sí. Escucha los eventos error y unhandledrejection, guarda en memoria los últimos mensajes y añádelos a un campo oculto al enviar. Quita antes los nombres de host para que los enlaces no cuenten para la puntuación de spam.

### ¿Puede quien reporta adjuntar una captura?

No como archivo; Ikiform rechaza los envíos con archivos con 422 files_not_supported. Pide en su lugar un enlace a una captura o grabación.

### ¿Pueden los reportes de bugs crear incidencias en GitHub o Linear automáticamente?

Sí, con un webhook a un pequeño relay que llame a la API de GitHub o Linear. Ikiform firma cada entrega y reintenta los fallos con el mismo webhook-id.

## Más guías

- [Formulario de feedback para un sitio web o una app](https://ikiform.com/es/guides/feedback-form.md): Pon un formulario de feedback en cualquier página o dentro de tu app: una valoración, un comentario, la página de origen, filtro antispam y avisos cuando la nota es baja.
- [Widget de feedback en React](https://ikiform.com/es/guides/feedback-form/react-feedback-widget.md): Un botón flotante de feedback para una app React que abre un panel pequeño, envía la pantalla, la versión de la app y la cuenta con cada respuesta, y nunca pierde un borrador.
- [Formulario de peticiones de funcionalidades](https://ikiform.com/es/guides/feedback-form/feature-request.md): Un formulario de peticiones de funcionalidades que pregunta por el problema en lugar de la solución, guarda las peticiones por tema, cuenta personas y no filas, y les avisa por email cuando sale.
- [Feedback de beta testers](https://ikiform.com/es/guides/feedback-form/beta-tester-feedback.md): Un formulario de feedback para betas con un enlace personal por tester y build, dos escalas de valoración y un resumen semanal que muestra si cada build mejoró.
- [Encuesta NPS](https://ikiform.com/es/guides/feedback-form/nps-survey.md): Haz una encuesta NPS con una puntuación de 0 a 10 en el email, una pregunta de seguimiento en tu propia página que cambia según la puntuación y un script que calcula el NPS.
- [Encuesta de cancelación](https://ikiform.com/es/guides/feedback-form/cancellation-survey.md): Pregunta por qué cancelan tus clientes dentro del flujo de baja de tu SaaS, deja Omitir a un clic, guarda el motivo en Stripe e Ikiform y nunca retrases la baja.
