# 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.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/feedback-form/bug-report

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.

Diagrama: Quais partes de um relato de bug a pessoa digita e quais a página adiciona

1. Quem relata escreve um resumo de uma linha.
2. Descreve o que fez logo antes de quebrar.
3. Escolhe o quanto isso a bloqueia.
4. Pode colar um link para uma captura de tela ou gravação.
5. No envio, a página adiciona o próprio endereço sem a query string.
6. Adiciona o user agent do navegador.
7. Adiciona o tamanho da janela e a densidade de pixels.
8. Adiciona os últimos erros de JavaScript que viu, sem os hostnames.

A linha de cima é tudo o que quem relata vê. A linha de baixo são inputs ocultos, preenchidos no momento em que a pessoa clica em Enviar.

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.referrer` não vai dizer o caminho. Envie `page_url` você 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-agent` do 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

Formulário: Bug report

- **O que deu errado?** (`summary`, text, obrigatório)
- **O que você fez logo antes?** (`steps`, textarea, obrigatório)
- **O que você esperava que acontecesse?** (`expected`, textarea)
- **O quanto isso bloqueia você?** (`severity`, radio, obrigatório)
- **Link para uma captura de tela ou gravação** (`screenshot_url`, url)
- **E-mail para perguntas de acompanhamento** (`email`, email)
- **Página** (`page_url`, url)
- **Navegador** (`user_agent`, text)
- **Viewport** (`viewport`, text)
- **Versão do app** (`app_version`, text)
- **Erros recentes** (`recent_errors`, textarea)

Os últimos cinco campos são inputs ocultos no formulário real. Com eles definidos, o Ikiform confere o tamanho e o tipo deles como de qualquer outro campo.

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.

**HTML**

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`.

`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**

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()`.

`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**

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.

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

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `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](https://ikiform.com/forms/bug-report) 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](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) tem esse relay, com verificação de assinatura e novas tentativas. A [documentação de webhooks](https://ikiform.com/pt-br/docs/webhooks.md) 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

1. Abra a página num navegador desktop, rode `undefinedFunction()` no console e depois envie um relato.
2. Abra o envio no painel. `recent_errors` contém o `ReferenceError` sem nenhum host `https://`.
3. `user_agent` mostra o seu navegador, não `node`, também nas versões de servidor.
4. `page_url` tem o caminho, mas não a query string.
5. Envie um relato com `steps` de 10 caracteres. O Ikiform responde `422` apontando `steps`, 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 com `crossorigin`.
- 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

### Como capturo o navegador e o sistema operacional num formulário de relato de bug?

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.

### Posso incluir erros do console num relato de bug?

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.

### Quem relata pode anexar uma captura de tela?

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.

### Os relatos de bug podem criar issues no GitHub ou no Linear automaticamente?

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.

## Mais guias

- [Formulário de feedback para um site ou app](https://ikiform.com/pt-br/guides/feedback-form.md): Coloque um formulário de feedback em qualquer página ou dentro do seu app: uma nota, um comentário, a página de origem, filtro de spam e alertas para notas baixas.
- [Widget de feedback em React](https://ikiform.com/pt-br/guides/feedback-form/react-feedback-widget.md): Um botão de feedback flutuante para um app React que abre um pequeno painel, envia a tela, a versão do app e a conta com cada resposta e nunca perde um rascunho.
- [Formulário de pedido de funcionalidade](https://ikiform.com/pt-br/guides/feedback-form/feature-request.md): Um formulário de pedido de funcionalidade que pergunta pelo problema em vez da solução, armazena pedidos por tema, conta pessoas, não linhas, e avisa quando sair.
- [Feedback de beta testers](https://ikiform.com/pt-br/guides/feedback-form/beta-tester-feedback.md): Um formulário de feedback de beta com um link pessoal por tester e build, duas escalas de nota e um resumo semanal que mostra se cada build melhorou.
- [Pesquisa NPS](https://ikiform.com/pt-br/guides/feedback-form/nps-survey.md): Rode uma pesquisa NPS com a nota de 0 a 10 no e-mail, uma pergunta de acompanhamento na sua própria página que muda com a nota e um script que calcula o NPS.
- [Pesquisa de cancelamento](https://ikiform.com/pt-br/guides/feedback-form/cancellation-survey.md): Pergunte por que os clientes cancelam dentro do fluxo de cancelamento do seu SaaS, deixe o Pular a um clique, salve o motivo no Stripe e no Ikiform e nunca atrase o cancelamento.
