# Tambahkan formulir laporan bug yang mencatat halaman, browser, dan langkah-langkahnya

> Formulir laporan bug yang menanyakan langkah dan tingkat keparahan, dan membiarkan halaman menambahkan URL, browser, viewport, dan error terbaru dengan sendirinya.

Terbit 2026-10-25 · https://ikiform.com/id/guides/feedback-form/bug-report

Tanyakan pelapor hanya hal yang mereka ketahui: apa yang salah, apa yang mereka lakukan sebelumnya, dan seberapa besar hal itu menghambat mereka. Biarkan halaman mengisi sisanya saat submit: URL halaman, user agent browser, viewport, dan beberapa error JavaScript terakhir. Kirim ke backend formulir dan teruskan setiap laporan ke issue tracker Anda lewat webhook.

## Apa yang berbeda dari laporan bug

Laporan bug hanya berguna jika Anda bisa mereproduksinya, dan pelapor jarang tahu detail yang Anda butuhkan. Tanyakan browser yang mereka pakai dan Anda akan mendapat "Chrome" atau "yang biasa". Minta URL-nya dan Anda mendapat nama halamannya. Jadi bagi formulirnya menjadi dua: pertanyaan yang hanya bisa dijawab pelapor, dan konteks yang sudah diketahui halaman.

Diagram: Bagian laporan bug yang diketik orang dan yang ditambahkan halaman

1. Pelapor menulis ringkasan satu baris.
2. Mereka menjelaskan apa yang mereka lakukan tepat sebelum terjadi kerusakan.
3. Mereka memilih seberapa parah hal itu menghambat mereka.
4. Mereka bisa menempel tautan ke screenshot atau rekaman.
5. Saat submit, halaman menambahkan alamatnya sendiri tanpa query string.
6. Halaman menambahkan user agent browser.
7. Halaman menambahkan ukuran jendela dan rasio piksel.
8. Halaman menambahkan beberapa error JavaScript terakhir yang dilihatnya, dengan hostname dihapus.

Baris atas adalah satu-satunya yang dilihat pelapor. Baris bawah adalah input tersembunyi, yang diisi saat mereka menekan Kirim.

Tiga batasan membentuk kodenya:

- **Referrer bukan halamannya.** Secara default browser hanya mengirim origin Anda pada kiriman lintas situs, jadi `meta.referrer` tidak akan memberi tahu path-nya. Kirim `page_url` sendiri.
- **Kiriman dari server menyembunyikan browser.** Ikiform mencatat user agent dari siapa pun yang membuat request. Dari server action, itu adalah server Anda. Teruskan header `user-agent` pengunjung sebagai field.
- **Stack trace penuh dengan tautan.** Setiap frame memiliki URL script, dan tiga tautan atau lebih menambah 2 pada skor spam. Hapus host sebelum Anda mengirim error, atau laporan dengan tautan screenshot yang ditempel bisa tergelincir menjadi spam.

## Field-nya

Formulir: Bug report

- **Apa yang salah?** (`summary`, text, wajib)
- **Apa yang Anda lakukan tepat sebelumnya?** (`steps`, textarea, wajib)
- **Apa yang Anda harapkan terjadi?** (`expected`, textarea)
- **Seberapa besar hal ini menghambat Anda?** (`severity`, radio, wajib)
- **Tautan ke screenshot atau rekaman** (`screenshot_url`, url)
- **Email untuk pertanyaan lanjutan** (`email`, email)
- **Halaman** (`page_url`, url)
- **Browser** (`user_agent`, text)
- **Viewport** (`viewport`, text)
- **Versi aplikasi** (`app_version`, text)
- **Error terbaru** (`recent_errors`, textarea)

Lima field terakhir adalah input tersembunyi di formulir sebenarnya. Setelah didefinisikan, Ikiform memeriksa panjang dan tipenya seperti field lainnya.

Tingkat keparahan ditulis sebagai dampaknya bagi pelapor, bukan sebagai label prioritas. Orang memilih "Saya tidak bisa melanjutkan" dengan akurat; mereka memilih "Kritis" untuk segalanya. Langkah punya minimum 20 karakter, yang mengubah "rusak" menjadi kalimat yang bisa Anda tindak lanjuti.

## Tangkap konteksnya

Browser mengisi field tersembunyi di handler `submit`, setelah orang selesai mengetik, sehingga daftar error mencakup apa pun yang salah selama mereka menulis laporan.

**HTML**

Formulir mengirim dengan cara biasa; script hanya mengisi input tersembunyi saat `submit`, sebelum browser menyusun request. Atur `data-app-version` di elemen `<html>` saat build. Browser mengirim langsung, jadi `meta.user_agent` milik Ikiform sama dengan field `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**

Modul kecil menyimpan lima error terakhir di memori dan mengembalikan konteks browser. Formulir membungkus action sehingga konteks ditambahkan ke `FormData` saat submit, dan server action menambahkan user agent pengunjung dari `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**

Modul `error-buffer.ts` yang sama ditaruh di `src/lib`. `use:enhance` memberi Anda `FormData` sebelum dikirim, jadi konteksnya dimasukkan di sana. Action membaca user agent dari request dan versi dari variabel env publik. Deklarasikan keduanya di `src/env.ts` dengan `defineEnvVars`, `PUBLIC_APP_VERSION` dengan `public: true`, agar `$app/env/private` dan `$app/env/public` bisa membacanya.

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

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

Pesan sukses menampilkan ID kiriman. Saat pelapor menghubungi support nanti, ID itu menemukan laporannya dalam satu pencarian. [Template laporan bug](https://ikiform.com/forms/bug-report) punya versi lebih pendek tanpa konteks yang ditangkap.

## Masuk ke issue tracker Anda

Laporan sebaiknya berada di tempat perbaikannya dilacak. Tambahkan webhook yang mengarah ke relay yang membuat issue: `summary` sebagai judul, langkah dan konteks sebagai isi, `severity` sebagai label. [Panduan Linear dan GitHub Issues](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) punya relay tersebut, lengkap dengan pemeriksaan tanda tangan dan percobaan ulang. [Dokumentasi webhook](https://ikiform.com/id/docs/webhooks.md) mendaftar setiap nilai `meta` yang dibawa payload. Taruh field yang ditangkap di blok yang diciutkan di akhir isi issue agar langkah-langkahnya tetap mudah dibaca.

| Field | Di issue |
| --- | --- |
| `summary` | Judul |
| `steps`, `expected` | Isi, paling awal |
| `severity` | Label, agar blocker bisa difilter |
| `page_url`, `user_agent`, `viewport`, `app_version` | Blok details di bawah langkah-langkah |
| `recent_errors` | Blok kode, paling akhir |

## Pastikan berfungsi

1. Buka halaman di browser desktop, jalankan `undefinedFunction()` di konsol, lalu kirim laporan.
2. Buka kiriman di dashboard. `recent_errors` berisi `ReferenceError` tanpa host `https://` di dalamnya.
3. `user_agent` menampilkan browser Anda, bukan `node`, juga di versi server.
4. `page_url` berisi path tetapi tanpa query string.
5. Kirim laporan dengan `steps` sepanjang 10 karakter. Ikiform menjawab `422` dengan menyebut `steps`, dan formulir menampilkan pesannya.

## Hal yang sering menjebak

- Unggah file belum didukung. Kiriman berisi file mendapat `422 files_not_supported`. Mintalah tautan ke screenshot, atau unggah gambar ke storage Anda sendiri terlebih dahulu lalu kirim URL-nya.
- Setiap nilai bisa sampai 16 KB. Lima error yang dipotong menjadi 400 karakter masih jauh di bawah itu; dump konsol lengkap tidak.
- Browser Chromium membekukan sebagian string user agent, seperti versi macOS, yang selalu terbaca `10_15_7`. Gunakan nama browser dan versi mayornya, bukan versi OS, saat melakukan triase.
- Error dari script di origin lain hanya menampilkan `Script error.` kecuali script itu disajikan dengan header CORS dan dimuat dengan `crossorigin`.
- Jangan menangkap isi formulir, local storage, atau cookie ke dalam laporan. Isinya bisa berupa password dan token, dan Anda akan menyimpannya dalam teks biasa.

## Pemecahan masalah

| Masalah | Yang harus dilakukan |
| --- | --- |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |
| `422 files_not_supported` | Formulir punya `<input type="file">` dengan file yang dipilih. Upload file belum didukung; hapus input itu atau minta link sebagai gantinya. |
| Laporan dengan tautan screenshot masuk ke spam | Hitung tautan di semua field. Tiga atau lebih menambah skor; ditambah pengulangan laporan yang sama, skornya mencapai ambang batas. Hapus host dari error dan tandai laporan sebagai bukan spam. |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |

## Pertanyaan

### Bagaimana cara mencatat browser dan OS di formulir laporan bug?

Baca navigator.userAgent di handler submit dan kirim sebagai field. Saat formulir mengirim langsung dari browser, Ikiform mencatat nilai yang sama sebagai meta.user_agent; saat formulir mengirim dari server Anda, teruskan header user-agent pengunjung.

### Bisakah saya menyertakan error konsol di laporan bug?

Bisa. Dengarkan event error dan unhandledrejection, simpan beberapa pesan terakhir di memori, dan tambahkan ke field tersembunyi saat submit. Hapus hostname lebih dulu agar tautannya tidak dihitung dalam skor spam.

### Bisakah pelapor melampirkan screenshot?

Tidak sebagai file; Ikiform menolak kiriman berisi file dengan 422 files_not_supported. Mintalah tautan ke screenshot atau rekaman sebagai gantinya.

### Bisakah laporan bug membuat issue GitHub atau Linear secara otomatis?

Bisa, dengan webhook ke relay kecil yang memanggil API GitHub atau Linear. Ikiform menandatangani setiap pengiriman dan mencoba ulang yang gagal dengan webhook-id yang sama.

## Panduan lainnya

- [Formulir feedback untuk website atau aplikasi](https://ikiform.com/id/guides/feedback-form.md): Pasang formulir feedback di halaman mana pun atau di dalam aplikasi Anda: rating, komentar, halaman asalnya, filter spam, dan peringatan saat skornya rendah.
- [Widget feedback React](https://ikiform.com/id/guides/feedback-form/react-feedback-widget.md): Tombol feedback mengambang untuk aplikasi React yang membuka panel kecil, mengirim layar, versi aplikasi, dan akun bersama setiap jawaban, dan tidak pernah kehilangan draf.
- [Formulir permintaan fitur](https://ikiform.com/id/guides/feedback-form/feature-request.md): Formulir permintaan fitur yang menanyakan masalahnya alih-alih solusinya, menyimpan permintaan per tema, menghitung orang bukan baris, dan mengirim email saat fiturnya rilis.
- [Feedback beta tester](https://ikiform.com/id/guides/feedback-form/beta-tester-feedback.md): Formulir feedback beta dengan tautan pribadi per tester dan build, dua skala rating, dan ringkasan mingguan yang menunjukkan apakah setiap build makin baik.
- [Survei NPS](https://ikiform.com/id/guides/feedback-form/nps-survey.md): Jalankan survei NPS dengan skor 0 sampai 10 di email, pertanyaan lanjutan di halaman Anda sendiri yang berubah sesuai skor, dan script yang menghitung NPS.
- [Survei pembatalan](https://ikiform.com/id/guides/feedback-form/cancellation-survey.md): Tanyakan mengapa pelanggan membatalkan di dalam alur pembatalan SaaS Anda, jaga Skip tetap satu klik, simpan alasannya ke Stripe dan Ikiform, dan jangan pernah menunda pembatalan.
