Formulir feedback untuk website atau aplikasi5 menit baca
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.
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.
- Pelapor menulis ringkasan satu baris.
- Mereka menjelaskan apa yang mereka lakukan tepat sebelum terjadi kerusakan.
- Mereka memilih seberapa parah hal itu menghambat mereka.
- Mereka bisa menempel tautan ke screenshot atau rekaman.
- Saat submit, halaman menambahkan alamatnya sendiri tanpa query string.
- Halaman menambahkan user agent browser.
- Halaman menambahkan ukuran jendela dan rasio piksel.
- Halaman menambahkan beberapa error JavaScript terakhir yang dilihatnya, dengan hostname dihapus.
Tiga batasan membentuk kodenya:
- Referrer bukan halamannya. Secara default browser hanya mengirim origin Anda pada kiriman lintas situs, jadi
meta.referrertidak akan memberi tahu path-nya. Kirimpage_urlsendiri. - 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-agentpengunjung 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
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
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.
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.
<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>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().
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>
);
}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.
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 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 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 punya relay tersebut, lengkap dengan pemeriksaan tanda tangan dan percobaan ulang. Dokumentasi webhook 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
- Buka halaman di browser desktop, jalankan
undefinedFunction()di konsol, lalu kirim laporan. - Buka kiriman di dashboard.
recent_errorsberisiReferenceErrortanpa hosthttps://di dalamnya. user_agentmenampilkan browser Anda, bukannode, juga di versi server.page_urlberisi path tetapi tanpa query string.- Kirim laporan dengan
stepssepanjang 10 karakter. Ikiform menjawab422dengan menyebutsteps, 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 dengancrossorigin. - 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
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.
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.
Tidak sebagai file; Ikiform menolak kiriman berisi file dengan 422 files_not_supported. Mintalah tautan ke screenshot atau rekaman sebagai gantinya.
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.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.