Formulário de feedback para um site ou app6 min de leitura
Adicione um formulário de relato de bug que captura a página, o navegador e os passos
Um formulário de relato de bug que pede às pessoas os passos e a gravidade, e deixa a página adicionar sozinha a URL, o navegador, o viewport e os erros recentes.
Peça a quem relata só o que essa pessoa sabe: o que deu errado, o que ela fez antes e o quanto isso a bloqueia. Deixe a página preencher o resto no momento do envio: a URL da página, o user agent do navegador, o viewport e os últimos erros de JavaScript. Envie a um backend de formulários e encaminhe cada relato para o seu issue tracker por webhook.
O que muda num relato de bug
Um relato de bug só é útil se você consegue reproduzi-lo, e quem relata raramente sabe os detalhes de que você precisa. Pergunte qual navegador a pessoa usa e você ouve "Chrome" ou "o normal". Peça a URL e você recebe o nome da página. Então divida o formulário em dois: perguntas que só quem relata sabe responder, e contexto que a página já conhece.
- Quem relata escreve um resumo de uma linha.
- Descreve o que fez logo antes de quebrar.
- Escolhe o quanto isso a bloqueia.
- Pode colar um link para uma captura de tela ou gravação.
- No envio, a página adiciona o próprio endereço sem a query string.
- Adiciona o user agent do navegador.
- Adiciona o tamanho da janela e a densidade de pixels.
- Adiciona os últimos erros de JavaScript que viu, sem os hostnames.
Três restrições moldam o código:
- O referrer não é a página. Por padrão, os navegadores mandam só a sua origem num envio entre sites, então
meta.referrernão vai dizer o caminho. Enviepage_urlvocê mesmo. - Um envio pelo servidor esconde o navegador. O Ikiform registra o user agent de quem faz a requisição. A partir de uma server action, esse é o seu servidor. Encaminhe o header
user-agentdo visitante como um campo. - Stack traces são cheios de links. Cada frame tem a URL de um script, e três ou mais links somam 2 à pontuação de spam. Remova os hosts antes de enviar os erros, ou um relato com um link de captura colado pode cair em spam.
Os campos
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
A gravidade é formulada como o efeito sobre quem relata, não como um rótulo de prioridade. As pessoas escolhem "Não consigo continuar" com precisão; escolhem "Crítico" para tudo. Os passos têm um mínimo de 20 caracteres, o que transforma "está quebrado" numa frase útil.
Capture o contexto
O navegador preenche os campos ocultos no handler de submit, depois que a pessoa terminou de digitar, então a lista de erros inclui qualquer coisa que deu errado enquanto ela escrevia o relato.
O formulário envia do jeito tradicional; o script só preenche os inputs ocultos durante o submit, antes de o navegador montar a requisição. Defina data-app-version no elemento <html> no momento do build. O navegador envia direto, então o meta.user_agent do próprio Ikiform bate com o 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>Um pequeno módulo guarda os últimos cinco erros na memória e devolve o contexto do navegador. O formulário envolve a action para que o contexto seja adicionado ao FormData no envio, e a server action adiciona o user agent do visitante a partir de 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>
);
}O mesmo módulo error-buffer.ts vai em src/lib. use:enhance entrega o FormData antes de ele ser enviado, então o contexto entra ali. A action lê o user agent da requisição e a versão de uma variável de ambiente pública. Declare as duas em src/env.ts com defineEnvVars, PUBLIC_APP_VERSION com public: true, para que $app/env/private e $app/env/public consigam lê-las.
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 em variáveis de ambiente só do servidor.A mensagem de sucesso mostra o id do envio. Quando quem relatou escrever ao suporte depois, esse id encontra o relato numa única busca. O modelo de relato de bug tem uma versão mais curta, sem o contexto capturado.
Para o seu issue tracker
Os relatos devem ficar onde a correção será acompanhada. Adicione um webhook apontando para um relay que cria uma issue: summary como título, os passos e o contexto como corpo, severity como label. O guia de Linear e GitHub Issues tem esse relay, com verificação de assinatura e novas tentativas. A documentação de webhooks lista todos os valores de meta que o payload traz. Coloque os campos capturados num bloco recolhido no fim do corpo da issue para que os passos continuem legíveis.
| Campo | Na issue |
|---|---|
summary | Título |
steps, expected | Corpo, primeiro |
severity | Um label, para filtrar os bloqueantes |
page_url, user_agent, viewport, app_version | Um bloco details abaixo dos passos |
recent_errors | Um bloco de código, por último |
Confira se funciona
- Abra a página num navegador desktop, rode
undefinedFunction()no console e depois envie um relato. - Abra o envio no painel.
recent_errorscontém oReferenceErrorsem nenhum hosthttps://. user_agentmostra o seu navegador, nãonode, também nas versões de servidor.page_urltem o caminho, mas não a query string.- Envie um relato com
stepsde 10 caracteres. O Ikiform responde422apontandosteps, e o formulário mostra a mensagem.
Pegadinhas
- Upload de arquivos ainda não é suportado. Um envio com arquivo recebe
422 files_not_supported. Peça um link para uma captura de tela, ou faça upload da imagem no seu próprio armazenamento antes e envie a URL dela. - Cada valor pode ter até 16 KB. Cinco erros cortados em 400 caracteres ficam bem abaixo disso; um dump completo do console não ficaria.
- Navegadores Chromium congelam partes da string de user agent, como a versão do macOS, que sempre aparece como
10_15_7. Na triagem, use o nome e a versão principal do navegador, não a versão do sistema operacional. - Erros de scripts de outra origem mostram só
Script error., a menos que esse script seja servido com headers CORS e carregado comcrossorigin. - Não capture o conteúdo de formulários, o local storage nem cookies no relato. Eles podem conter senhas e tokens, e você os armazenaria em texto puro.
Solução de problemas
| Problema | O que fazer |
|---|---|
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
422 files_not_supported | O formulário tem um <input type="file"> com um arquivo escolhido. Upload de arquivos ainda não é suportado; remova o input ou peça um link. |
| Relatos com links de captura caem em spam | Conte os links em todos os campos. Três ou mais somam à pontuação; com uma repetição do mesmo relato, ela atinge o limite. Remova os hosts dos erros e marque o relato como não spam. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
Perguntas
Leia navigator.userAgent no handler de submit e envie como um campo. Quando o formulário envia direto do navegador, o Ikiform registra o mesmo valor como meta.user_agent; quando envia a partir do seu servidor, encaminhe o header user-agent do visitante.
Sim. Escute os eventos error e unhandledrejection, guarde as últimas mensagens na memória e adicione-as a um campo oculto no envio. Remova os hostnames antes, para que os links não contem na pontuação de spam.
Não como arquivo; o Ikiform recusa envios com arquivos com 422 files_not_supported. Peça um link para uma captura de tela ou gravação.
Sim, com um webhook para um pequeno relay que chama a API do GitHub ou do Linear. O Ikiform assina cada entrega e tenta de novo as que falham com o mesmo webhook-id.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.