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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/feedback-form/bug-report

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.

Diagramm: Welche Teile eines Bug-Reports die Person eintippt und welche die Seite ergänzt

1. Die meldende Person schreibt eine einzeilige Zusammenfassung.
2. Sie beschreibt, was sie direkt vor dem Fehler getan hat.
3. Sie wählt, wie sehr es sie blockiert.
4. Sie kann einen Link zu einem Screenshot oder einer Aufnahme einfügen.
5. Beim Absenden ergänzt die Seite ihre eigene Adresse ohne Query-String.
6. Sie ergänzt den User-Agent des Browsers.
7. Sie ergänzt Fenstergröße und Pixel Ratio.
8. Sie ergänzt die letzten JavaScript-Fehler, die sie gesehen hat, ohne Hostnamen.

Die obere Reihe ist alles, was die meldende Person sieht. Die untere Reihe sind Hidden Inputs, die im Moment des Klicks auf Senden befüllt werden.

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.referrer` den Pfad nicht. Sende `page_url` selbst.
- **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

Formular: Bug report

- **Was ist schiefgegangen?** (`summary`, text, Pflichtfeld)
- **Was hast du direkt davor getan?** (`steps`, textarea, Pflichtfeld)
- **Was hast du erwartet?** (`expected`, textarea)
- **Wie sehr blockiert es dich?** (`severity`, radio, Pflichtfeld)
- **Link zu einem Screenshot oder einer Aufnahme** (`screenshot_url`, url)
- **E-Mail für Rückfragen** (`email`, email)
- **Seite** (`page_url`, url)
- **Browser** (`user_agent`, text)
- **Viewport** (`viewport`, text)
- **App-Version** (`app_version`, text)
- **Letzte Fehler** (`recent_errors`, textarea)

Die letzten fünf Felder sind im echten Formular Hidden Inputs. Sind sie definiert, prüft Ikiform ihre Länge und ihren Typ wie bei jedem anderen Feld.

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.

**HTML**

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.

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

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

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

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.

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

> Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform **Allowed domains** und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das [Rate-Limit](https://ikiform.com/de/docs/rate-limits.md) Relaxed oder Custom, und halte `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](https://ikiform.com/forms/bug-report) 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](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) enthält dieses Relay, mit Signaturprüfung und Wiederholungen. Die [Webhook-Doku](https://ikiform.com/de/docs/webhooks.md) 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

1. Öffne die Seite in einem Desktop-Browser, führe `undefinedFunction()` in der Konsole aus und sende dann einen Bericht.
2. Öffne die Einsendung im Dashboard. `recent_errors` enthält den `ReferenceError` ohne `https://`-Host.
3. `user_agent` zeigt auch in den Server-Versionen deinen Browser, nicht `node`.
4. `page_url` enthält den Pfad, aber nicht den Query-String.
5. Sende einen Bericht mit 10 Zeichen in `steps`. Ikiform antwortet mit `422` und nennt `steps`, 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_7` lautet. 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 mit `crossorigin` geladen.
- 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

### Wie erfasse ich Browser und OS in einem Bug-Report-Formular?

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.

### Kann ich Konsolenfehler in einen Bug-Report aufnehmen?

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.

### Können Meldende einen Screenshot anhängen?

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.

### Können Bug-Reports automatisch GitHub- oder Linear-Issues anlegen?

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.

## Weitere Anleitungen

- [Feedback-Formular für eine Website oder App](https://ikiform.com/de/guides/feedback-form.md): Setze ein Feedback-Formular auf jede Seite oder in deine App: eine Bewertung, ein Kommentar, die Herkunftsseite, Spamfilter und Alerts bei niedrigen Werten.
- [Feedback-Widget für React](https://ikiform.com/de/guides/feedback-form/react-feedback-widget.md): Ein schwebender Feedback-Button für eine React-App, der ein kleines Panel öffnet, Ansicht, App-Version und Konto mit jeder Antwort sendet und nie einen Entwurf verliert.
- [Formular für Feature-Wünsche](https://ikiform.com/de/guides/feedback-form/feature-request.md): Ein Formular für Feature-Wünsche, das nach dem Problem statt nach der Lösung fragt, Wünsche nach Thema speichert, Personen statt Zeilen zählt und sie beim Release anschreibt.
- [Feedback von Beta-Testern](https://ikiform.com/de/guides/feedback-form/beta-tester-feedback.md): Ein Beta-Feedback-Formular mit einem persönlichen Link pro Tester und Build, zwei Bewertungsskalen und einer Wochenübersicht, die zeigt, ob jeder Build besser wurde.
- [NPS-Umfrage](https://ikiform.com/de/guides/feedback-form/nps-survey.md): Führe eine NPS-Umfrage mit einem Wert von 0 bis 10 in der E-Mail durch, mit einer Folgefrage auf deiner eigenen Seite, die sich mit dem Wert ändert, und einem Skript, das den NPS berechnet.
- [Kündigungsumfrage](https://ikiform.com/de/guides/feedback-form/cancellation-survey.md): Frag im Kündigungsablauf deines SaaS, warum Kunden kündigen, halte Überspringen einen Klick entfernt, speichere den Grund in Stripe und Ikiform und verzögere die Kündigung nie.
