Formulario de feedback para un sitio web o una app6 min de lectura
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.
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.
- Quien reporta escribe un resumen de una línea.
- Describe lo que hizo justo antes de que fallara.
- Elige cuánto le bloquea.
- Puede pegar un enlace a una captura o una grabación.
- Al enviar, la página añade su propia dirección sin la query string.
- Añade el user agent del navegador.
- Añade el tamaño de la ventana y el pixel ratio.
- Añade los últimos errores de JavaScript que vio, sin los nombres de host.
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.referrerno te dirá la ruta. Envíapage_urltú 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-agentdel 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
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
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.
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.
<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>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().
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"),
};
}"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." };
}"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>
);
}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.
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;<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}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 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 tiene ese relay, con comprobación de firmas y reintentos. La documentación de webhooks 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
- Abre la página en un navegador de escritorio, ejecuta
undefinedFunction()en la consola y luego envía un reporte. - Abre el envío en el panel.
recent_errorscontiene elReferenceErrorsin ningún hosthttps://. user_agentmuestra tu navegador, nonode, también en las versiones de servidor.page_urltiene la ruta pero no la query string.- Envía un reporte con un
stepsde 10 caracteres. Ikiform responde422nombrandosteps, 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 concrossorigin. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.