# Ajouter un formulaire de signalement de bug qui capture la page, le navigateur et les étapes

> Un formulaire de signalement de bug qui demande les étapes et la gravité, et laisse la page ajouter elle-même l'URL, le navigateur, le viewport et les erreurs récentes.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/feedback-form/bug-report

Ne demandez à la personne que ce qu'elle sait : ce qui n'a pas marché, ce qu'elle a fait juste avant et à quel point ça la bloque. Laissez la page remplir le reste au moment de l'envoi : l'URL de la page, le user agent du navigateur, le viewport et les dernières erreurs JavaScript. Envoyez le tout à un backend de formulaires et transmettez chaque signalement à votre outil de tickets par webhook.

## Ce qui change avec un signalement de bug

Un signalement de bug n'est utile que si vous pouvez le reproduire, et la personne qui signale connaît rarement les détails dont vous avez besoin. Demandez-lui quel navigateur elle utilise et vous obtenez « Chrome » ou « le normal ». Demandez l'URL et vous obtenez le nom de la page. Séparez donc le formulaire en deux : les questions auxquelles seule la personne peut répondre, et le contexte que la page connaît déjà.

Schéma : Ce que la personne tape et ce que la page ajoute dans un signalement de bug

1. La personne écrit un résumé d'une ligne.
2. Elle décrit ce qu'elle a fait juste avant que ça casse.
3. Elle indique à quel point ça la bloque.
4. Elle peut coller un lien vers une capture d'écran ou un enregistrement.
5. À l'envoi, la page ajoute sa propre adresse sans les paramètres d'URL.
6. Elle ajoute le user agent du navigateur.
7. Elle ajoute la taille de la fenêtre et le ratio de pixels.
8. Elle ajoute les dernières erreurs JavaScript vues, sans les noms d'hôte.

La ligne du haut est tout ce que voit la personne. Celle du bas, ce sont des inputs masqués, remplis au moment où elle appuie sur Envoyer.

Trois contraintes guident le code :

- **Le referrer n'est pas la page.** Par défaut, les navigateurs n'envoient que votre origine sur un envoi cross-site, donc `meta.referrer` ne vous donnera pas le chemin. Envoyez `page_url` vous-même.
- **Un envoi depuis le serveur masque le navigateur.** Ikiform enregistre le user agent de qui fait la requête. Depuis une server action, c'est votre serveur. Transmettez l'en-tête `user-agent` du visiteur dans un champ.
- **Les stack traces sont pleines de liens.** Chaque frame contient l'URL d'un script, et trois liens ou plus ajoutent 2 au score de spam. Retirez les hôtes avant d'envoyer les erreurs, sinon un signalement avec un lien de capture collé peut basculer en spam.

## Les champs

Formulaire : Bug report

- **Qu'est-ce qui n'a pas marché ?** (`summary`, text, obligatoire)
- **Qu'avez-vous fait juste avant ?** (`steps`, textarea, obligatoire)
- **À quoi vous attendiez-vous ?** (`expected`, textarea)
- **À quel point cela vous bloque-t-il ?** (`severity`, radio, obligatoire)
- **Lien vers une capture d'écran ou un enregistrement** (`screenshot_url`, url)
- **E-mail pour les questions de suivi** (`email`, email)
- **Page** (`page_url`, url)
- **Navigateur** (`user_agent`, text)
- **Viewport** (`viewport`, text)
- **Version de l'application** (`app_version`, text)
- **Erreurs récentes** (`recent_errors`, textarea)

Les cinq derniers champs sont des inputs masqués dans le vrai formulaire. Une fois définis, Ikiform vérifie leur longueur et leur type comme pour tout autre champ.

La gravité est formulée selon son effet sur la personne, pas comme un niveau de priorité. Les gens choisissent « Je ne peux pas continuer » avec justesse ; ils choisissent « Critique » pour tout. Les étapes demandent au moins 20 caractères, ce qui transforme « ça marche pas » en une phrase exploitable.

## Capturer le contexte

Le navigateur remplit les champs masqués dans le handler `submit`, une fois que la personne a fini de taper, pour que la liste d'erreurs inclue tout ce qui a mal tourné pendant qu'elle rédigeait le signalement.

**HTML**

Le formulaire envoie de façon classique ; le script ne fait que remplir les inputs masqués pendant `submit`, avant que le navigateur ne construise la requête. Définissez `data-app-version` sur l'élément `<html>` au build. Le navigateur envoie directement, donc le `meta.user_agent` d'Ikiform correspond au champ `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**

Un petit module garde les cinq dernières erreurs en mémoire et renvoie le contexte du navigateur. Le formulaire enveloppe l'action pour que le contexte soit ajouté au `FormData` à l'envoi, et la server action ajoute le user agent du visiteur depuis `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**

Le même module `error-buffer.ts` va dans `src/lib`. `use:enhance` vous donne le `FormData` avant l'envoi, c'est donc là que va le contexte. L'action lit le user agent depuis la requête et la version depuis une variable d'environnement publique. Déclarez les deux dans `src/env.ts` avec `defineEnvVars`, `PUBLIC_APP_VERSION` avec `public: true`, pour que `$app/env/private` et `$app/env/public` puissent les lire.

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

> Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore **Allowed domains** et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la [limite de débit](https://ikiform.com/fr/docs/rate-limits.md) Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez `IKIFORM_SECRET_KEY` dans des variables d'environnement réservées au serveur.

Le message de succès affiche l'id de la soumission. Quand la personne écrit plus tard au support, cet id retrouve le signalement en une recherche. Le [modèle de signalement de bug](https://ikiform.com/forms/bug-report) propose une version plus courte, sans le contexte capturé.

## Dans votre outil de tickets

Les signalements doivent aller là où la correction sera suivie. Ajoutez un webhook qui pointe vers un relais qui crée un ticket : `summary` comme titre, les étapes et le contexte comme corps, `severity` comme label. Le [guide Linear et GitHub Issues](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md) fournit ce relais, avec vérification de signature et nouvelles tentatives. La [documentation des webhooks](https://ikiform.com/fr/docs/webhooks.md) liste chaque valeur `meta` que contient le payload. Placez les champs capturés dans un bloc replié à la fin du corps du ticket, pour que les étapes restent lisibles.

| Champ | Dans le ticket |
| --- | --- |
| `summary` | Titre |
| `steps`, `expected` | Corps, en premier |
| `severity` | Un label, pour filtrer les blocages |
| `page_url`, `user_agent`, `viewport`, `app_version` | Un bloc de détails sous les étapes |
| `recent_errors` | Un bloc de code, en dernier |

## Vérifiez que tout fonctionne

1. Ouvrez la page dans un navigateur de bureau, lancez `undefinedFunction()` dans la console, puis envoyez un signalement.
2. Ouvrez la soumission dans le tableau de bord. `recent_errors` contient la `ReferenceError` sans aucun hôte `https://`.
3. `user_agent` affiche votre navigateur, pas `node`, y compris dans les versions serveur.
4. `page_url` contient le chemin mais pas les paramètres d'URL.
5. Envoyez un signalement avec des `steps` de 10 caractères. Ikiform répond `422` en nommant `steps`, et le formulaire affiche le message.

## Pièges

- L'envoi de fichiers n'est pas encore pris en charge. Un envoi avec un fichier reçoit `422 files_not_supported`. Demandez un lien vers une capture d'écran, ou envoyez d'abord l'image sur votre propre stockage et soumettez son URL.
- Chaque valeur peut faire jusqu'à 16 Ko. Cinq erreurs coupées à 400 caractères restent bien en dessous ; un dump complet de la console, non.
- Les navigateurs Chromium figent certaines parties de la chaîne user agent, comme la version de macOS, qui vaut toujours `10_15_7`. Pour le tri, utilisez le nom du navigateur et sa version majeure, pas la version de l'OS.
- Les erreurs des scripts d'une autre origine n'affichent que `Script error.`, sauf si ce script est servi avec des en-têtes CORS et chargé avec `crossorigin`.
- Ne capturez dans le signalement ni le contenu des formulaires, ni le local storage, ni les cookies. Ils peuvent contenir des mots de passe et des tokens, que vous stockeriez en clair.

## Dépannage

| Problème | Que faire |
| --- | --- |
| `422 validation_failed` | L'envoi ne correspond pas à la définition des champs du formulaire. `error.fields` nomme chaque champ et la raison. Faites correspondre le `name` de l'input à l'id du champ. |
| `422 files_not_supported` | Le formulaire a un `<input type="file">` avec un fichier choisi. L'envoi de fichiers n'est pas encore pris en charge ; retirez l'input ou demandez plutôt un lien. |
| Les signalements avec des liens de capture tombent en spam | Comptez les liens sur l'ensemble des champs. Trois ou plus augmentent le score ; avec une répétition du même signalement, il atteint le seuil. Retirez les hôtes des erreurs et marquez le signalement comme non-spam. |
| `403 origin_not_allowed`, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les **Allowed domains** du formulaire. Ajoutez-le, plus `localhost` pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |

## Questions

### Comment capturer le navigateur et l'OS dans un formulaire de signalement de bug ?

Lisez navigator.userAgent dans le handler submit et envoyez-le dans un champ. Quand le formulaire envoie directement depuis le navigateur, Ikiform enregistre la même valeur dans meta.user_agent ; quand il envoie depuis votre serveur, transmettez l'en-tête user-agent du visiteur.

### Puis-je inclure les erreurs de la console dans un signalement de bug ?

Oui. Écoutez les événements error et unhandledrejection, gardez les derniers messages en mémoire et ajoutez-les à un champ masqué à l'envoi. Retirez d'abord les noms d'hôte pour que les liens ne comptent pas dans le score de spam.

### Les personnes peuvent-elles joindre une capture d'écran ?

Pas sous forme de fichier ; Ikiform refuse les envois avec fichiers avec 422 files_not_supported. Demandez plutôt un lien vers une capture ou un enregistrement.

### Les signalements de bug peuvent-ils créer automatiquement des tickets GitHub ou Linear ?

Oui, avec un webhook vers un petit relais qui appelle l'API de GitHub ou de Linear. Ikiform signe chaque livraison et réessaie les échecs avec le même webhook-id.

## Autres guides

- [Formulaire de feedback pour un site ou une application](https://ikiform.com/fr/guides/feedback-form.md): Placez un formulaire de feedback sur n'importe quelle page ou dans votre application : une note, un commentaire, la page d'origine, le filtrage du spam et des alertes quand une note est basse.
- [Widget de feedback React](https://ikiform.com/fr/guides/feedback-form/react-feedback-widget.md): Un bouton de feedback flottant pour une application React qui ouvre un petit panneau, envoie l'écran, la version et le compte avec chaque réponse, et ne perd jamais un brouillon.
- [Formulaire de demande de fonctionnalité](https://ikiform.com/fr/guides/feedback-form/feature-request.md): Un formulaire de demande de fonctionnalité qui demande le problème plutôt que la solution, range les demandes par thème, compte les personnes et non les lignes, et les prévient quand c'est livré.
- [Feedback des bêta-testeurs](https://ikiform.com/fr/guides/feedback-form/beta-tester-feedback.md): Un formulaire de feedback bêta avec un lien personnel par testeur et par build, deux échelles de notation et un résumé hebdomadaire qui montre si chaque build s'est amélioré.
- [Enquête NPS](https://ikiform.com/fr/guides/feedback-form/nps-survey.md): Menez une enquête NPS avec un score de 0 à 10 dans l'e-mail, une question de suivi sur votre propre page qui change selon le score, et un script qui calcule le NPS.
- [Enquête de résiliation](https://ikiform.com/fr/guides/feedback-form/cancellation-survey.md): Demandez pourquoi vos clients résilient dans le parcours d'annulation de votre SaaS, gardez Passer à un clic, enregistrez la raison dans Stripe et Ikiform, sans jamais retarder la résiliation.
