Feedback-Formular für eine Website oder App6 Min. Lesezeit
Bug-Report-Formular hinzufügen, das Seite, Browser und Schritte erfasst
Ein Bug-Report-Formular, das nach Schritten und Schweregrad fragt und URL, Browser, Viewport und letzte Fehler von der Seite selbst ergänzen lässt.
Frag die meldende Person nur nach dem, was sie weiß: was schiefging, was sie davor getan hat und wie sehr es sie blockiert. Lass die Seite den Rest beim Absenden ergänzen: die Seiten-URL, den User-Agent des Browsers, den Viewport und die letzten JavaScript-Fehler. Sende alles an ein Formular-Backend und leite jeden Bericht per Webhook an deinen Issue-Tracker weiter.
Was an einem Bug-Report anders ist
Ein Bug-Report ist nur nützlich, wenn du den Fehler reproduzieren kannst, und Meldende kennen die Details, die du brauchst, selten. Fragst du jemanden nach dem Browser, bekommst du „Chrome“ oder „der normale“. Fragst du nach der URL, bekommst du den Namen der Seite. Teile das Formular also in zwei Teile: Fragen, die nur die meldende Person beantworten kann, und Kontext, den die Seite schon kennt.
- Die meldende Person schreibt eine einzeilige Zusammenfassung.
- Sie beschreibt, was sie direkt vor dem Fehler getan hat.
- Sie wählt, wie sehr es sie blockiert.
- Sie kann einen Link zu einem Screenshot oder einer Aufnahme einfügen.
- Beim Absenden ergänzt die Seite ihre eigene Adresse ohne Query-String.
- Sie ergänzt den User-Agent des Browsers.
- Sie ergänzt Fenstergröße und Pixel Ratio.
- Sie ergänzt die letzten JavaScript-Fehler, die sie gesehen hat, ohne Hostnamen.
Drei Einschränkungen prägen den Code:
- Der Referrer ist nicht die Seite. Browser senden bei einem Cross-Site-Post standardmäßig nur deinen Origin, also verrät dir
meta.referrerden Pfad nicht. Sendepage_urlselbst. - Ein Server-Post verbirgt den Browser. Ikiform speichert den User-Agent dessen, der den Request stellt. Bei einer Server Action ist das dein Server. Leite den
user-agent-Header des Besuchers als Feld weiter. - Stacktraces sind voller Links. Jeder Frame hat eine Skript-URL, und drei oder mehr Links erhöhen den Spam-Score um 2. Entferne die Hosts, bevor du Fehler sendest, sonst kann ein Bericht mit einem eingefügten Screenshot-Link in den Spam kippen.
Die Felder
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
Der Schweregrad ist als Auswirkung auf die meldende Person formuliert, nicht als Prioritätslabel. Leute wählen „Ich komme nicht weiter“ treffend; „Kritisch“ wählen sie für alles. Die Schritte haben ein Minimum von 20 Zeichen, was aus „geht nicht“ einen Satz macht, mit dem du arbeiten kannst.
Den Kontext erfassen
Der Browser füllt die Hidden Fields im submit-Handler, nachdem die Person fertig getippt hat, sodass die Fehlerliste alles enthält, was schiefging, während sie den Bericht schrieb.
Das Formular sendet auf dem normalen Weg; das Skript füllt nur während submit die Hidden Inputs, bevor der Browser den Request baut. Setze data-app-version beim Build auf das <html>-Element. Der Browser sendet direkt, also stimmt Ikiforms eigenes meta.user_agent mit dem Feld user_agent überein.
<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>Ein kleines Modul hält die letzten fünf Fehler im Speicher und gibt den Browser-Kontext zurück. Das Formular umschließt die Action, sodass der Kontext beim Absenden zu den FormData hinzugefügt wird, und die Server Action ergänzt den User-Agent des Besuchers aus 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>
);
}Dasselbe error-buffer.ts-Modul kommt in src/lib. use:enhance gibt dir die FormData, bevor sie gesendet werden, also kommt der Kontext dort hinein. Die Action liest den User-Agent aus dem Request und die Version aus einer öffentlichen Umgebungsvariable. Deklariere beide in src/env.ts mit defineEnvVars, PUBLIC_APP_VERSION mit public: true, damit $app/env/private und $app/env/public sie lesen können.
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 in reinen Server-Umgebungsvariablen.Die Erfolgsmeldung zeigt die ID der Einsendung. Schreibt die meldende Person später an den Support, findet diese ID den Bericht mit einer Suche. Die Bug-Report-Vorlage hat eine kürzere Version ohne den erfassten Kontext.
In deinen Issue-Tracker
Berichte gehören dorthin, wo die Behebung verfolgt wird. Füge einen Webhook hinzu, der auf ein Relay zeigt, das ein Issue anlegt: summary als Titel, Schritte und Kontext als Body, severity als Label. Die Anleitung zu Linear und GitHub Issues enthält dieses Relay, mit Signaturprüfung und Wiederholungen. Die Webhook-Doku listet jeden meta-Wert auf, den der Payload enthält. Setze die erfassten Felder in einen eingeklappten Block am Ende des Issue-Bodys, damit die Schritte lesbar bleiben.
| Feld | Im Issue |
|---|---|
summary | Titel |
steps, expected | Body, zuerst |
severity | Ein Label, damit sich Blocker filtern lassen |
page_url, user_agent, viewport, app_version | Ein details-Block unter den Schritten |
recent_errors | Ein Codeblock, zuletzt |
Prüfen, ob es funktioniert
- Öffne die Seite in einem Desktop-Browser, führe
undefinedFunction()in der Konsole aus und sende dann einen Bericht. - Öffne die Einsendung im Dashboard.
recent_errorsenthält denReferenceErrorohnehttps://-Host. user_agentzeigt auch in den Server-Versionen deinen Browser, nichtnode.page_urlenthält den Pfad, aber nicht den Query-String.- Sende einen Bericht mit 10 Zeichen in
steps. Ikiform antwortet mit422und nenntsteps, und das Formular zeigt die Meldung.
Stolperfallen
- Datei-Uploads werden noch nicht unterstützt. Ein Post mit einer Datei bekommt
422 files_not_supported. Bitte um einen Link zu einem Screenshot oder lade das Bild zuerst in deinen eigenen Speicher hoch und sende seine URL. - Jeder Wert kann bis zu 16 KB groß sein. Fünf auf 400 Zeichen gekürzte Fehler bleiben weit darunter; ein kompletter Konsolen-Dump nicht.
- Chromium-Browser frieren Teile des User-Agent-Strings ein, etwa die macOS-Version, die immer
10_15_7lautet. Nutze bei der Triage Browsername und Hauptversion, nicht die OS-Version. - Fehler aus Skripten von einem anderen Origin zeigen nur
Script error., außer das Skript wird mit CORS-Headern ausgeliefert und mitcrossorigingeladen. - Erfasse keine Formularinhalte, keinen Local Storage und keine Cookies im Bericht. Sie können Passwörter und Tokens enthalten, und du würdest sie im Klartext speichern.
Fehlerbehebung
| Problem | Was zu tun ist |
|---|---|
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
422 files_not_supported | Das Formular hat ein <input type="file"> mit ausgewählter Datei. Datei-Uploads werden noch nicht unterstützt; entferne das Eingabefeld oder bitte stattdessen um einen Link. |
| Berichte mit Screenshot-Links landen im Spam | Zähle die Links über alle Felder. Drei oder mehr erhöhen den Score; mit einer Wiederholung desselben Berichts erreicht er den Schwellenwert. Entferne Hosts aus den Fehlern und markiere den Bericht als kein Spam. |
403 origin_not_allowed, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |
Fragen
Lies navigator.userAgent im Submit-Handler und sende den Wert als Feld. Sendet das Formular direkt aus dem Browser, speichert Ikiform denselben Wert als meta.user_agent; sendet es von deinem Server, leite den user-agent-Header des Besuchers weiter.
Ja. Hör auf error- und unhandledrejection-Events, halte die letzten Meldungen im Speicher und füge sie beim Absenden in ein Hidden Field ein. Entferne vorher die Hostnamen, damit die Links nicht in den Spam-Score einfließen.
Nicht als Datei; Ikiform lehnt Posts mit Dateien mit 422 files_not_supported ab. Bitte stattdessen um einen Link zu einem Screenshot oder einer Aufnahme.
Ja, mit einem Webhook an ein kleines Relay, das die API von GitHub oder Linear aufruft. Ikiform signiert jede Zustellung und wiederholt Fehlschläge mit derselben webhook-id.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.