# ページ、ブラウザー、手順を記録するバグ報告フォームを追加する

> 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。

公開日 2026-10-25 · https://ikiform.com/ja/guides/feedback-form/bug-report

報告者には本人が知っていることだけを尋ねます。何がおかしかったか、その直前に何をしたか、どれほど作業が止まるかです。残りは送信時にページが埋めます。ページの URL、ブラウザーのユーザーエージェント、ビューポート、直近の JavaScript エラーです。フォームバックエンドに送信し、各報告を Webhook で課題管理ツールに転送します。

## バグ報告の違い

バグ報告は再現できて初めて役に立ちますが、報告者が必要な詳細を知っていることはまれです。どのブラウザーかと尋ねれば「Chrome」や「いつものやつ」と返ってきます。URL を尋ねればページの名前が返ってきます。そこでフォームを 2 つに分けます。報告者にしか答えられない質問と、ページがすでに知っている情報です。

図: バグ報告のうち、本人が入力する部分とページが追加する部分

1. 報告者が 1 行の要約を書きます。
2. 壊れる直前に何をしたかを書きます。
3. どれほど作業が止まるかを選びます。
4. スクリーンショットや録画へのリンクを貼れます。
5. 送信時に、ページがクエリ文字列を除いた自分のアドレスを追加します。
6. ブラウザーのユーザーエージェントを追加します。
7. ウィンドウサイズとピクセル比を追加します。
8. 直近に発生した JavaScript エラーを、ホスト名を除いて追加します。

報告者に見えるのは上の段だけです。下の段は hidden 入力欄で、送信を押した瞬間に埋まります。

コードを形づくる制約が 3 つあります。

- **リファラーはページではありません。** ブラウザーはクロスサイトの送信ではデフォルトで origin しか送らないので、`meta.referrer` からパスはわかりません。`page_url` を自分で送ってください。
- **サーバーからの送信ではブラウザーが隠れます。** Ikiform はリクエストを送った側のユーザーエージェントを記録します。サーバーアクションからなら、それは自分のサーバーです。訪問者の `user-agent` ヘッダーをフィールドとして転送してください。
- **スタックトレースはリンクだらけです。** 各フレームにスクリプトの URL があり、リンクが 3 つ以上あるとスパムスコアに 2 が加わります。エラーを送る前にホストを除去しないと、スクリーンショットのリンクを貼った報告がスパムに傾くことがあります。

## フィールド

フォーム: Bug report

- **何がおかしかったですか？** (`summary`, text, 必須)
- **直前に何をしましたか？** (`steps`, textarea, 必須)
- **本来はどうなるはずでしたか？** (`expected`, textarea)
- **どれほど作業が止まりますか？** (`severity`, radio, 必須)
- **スクリーンショットや録画へのリンク** (`screenshot_url`, url)
- **追加の質問用のメールアドレス** (`email`, email)
- **ページ** (`page_url`, url)
- **ブラウザー** (`user_agent`, text)
- **ビューポート** (`viewport`, text)
- **アプリのバージョン** (`app_version`, text)
- **直近のエラー** (`recent_errors`, textarea)

実際のフォームでは、最後の 5 つのフィールドは hidden 入力欄です。定義しておけば、Ikiform はほかのフィールドと同じように長さと型をチェックします。

深刻度は優先度のラベルではなく、報告者にどう影響するかで表現します。「先に進めない」なら人は正確に選びますが、「Critical」だと何でもそれを選びます。手順は最低 20 文字なので、「壊れてる」が対応できる文章に変わります。

## 状況を記録する

ブラウザーは、本人が入力し終えたあとの `submit` ハンドラーで hidden フィールドを埋めます。そのため、報告を書いている間に起きたエラーもエラー一覧に含まれます。

**HTML**

フォームは普通の方法で送信し、スクリプトは `submit` の間、ブラウザーがリクエストを組み立てる前に hidden 入力欄を埋めるだけです。ビルド時に `<html>` 要素に `data-app-version` を設定してください。ブラウザーが直接送信するので、Ikiform 自身の `meta.user_agent` は `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**

小さなモジュールが直近 5 件のエラーをメモリに保持し、ブラウザーの情報を返します。フォームは action をラップして送信時に `FormData` へ情報を追加し、サーバーアクションが `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**

同じ `error-buffer.ts` モジュールを `src/lib` に置きます。`use:enhance` は送信前の `FormData` を渡してくれるので、情報はそこに入れます。action はリクエストからユーザーエージェントを、公開環境変数からバージョンを読みます。両方を `src/env.ts` で `defineEnvVars` を使って宣言し、`PUBLIC_APP_VERSION` には `public: true` を付けると、`$app/env/private` と `$app/env/public` から読めます。

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

> サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

成功メッセージには送信 ID を表示します。報告者があとでサポートに連絡してきたとき、その ID で報告が 1 回の検索で見つかります。[バグ報告テンプレート](https://ikiform.com/forms/bug-report)には、状況の記録を省いた短い版があります。

## 課題管理ツールへ

報告は修正を追跡する場所に置くべきです。課題を作成するリレーを指す Webhook を追加します。`summary` をタイトルに、手順と状況を本文に、`severity` をラベルにします。署名チェックとリトライを含むそのリレーは [Linear と GitHub Issues のガイド](https://ikiform.com/blog/form-submissions-to-linear-and-github-issues.md)にあります。ペイロードに含まれるすべての `meta` の値は [Webhook のドキュメント](https://ikiform.com/ja/docs/webhooks.md)に一覧があります。手順が読みやすいよう、記録したフィールドは課題本文の最後の折りたたみブロックに入れてください。

| フィールド | 課題では |
| --- | --- |
| `summary` | タイトル |
| `steps`, `expected` | 本文の先頭 |
| `severity` | ラベル。blocker で絞り込める |
| `page_url`, `user_agent`, `viewport`, `app_version` | 手順の下の details ブロック |
| `recent_errors` | 最後にコードブロック |

## 動作を確認する

1. デスクトップのブラウザーでページを開き、コンソールで `undefinedFunction()` を実行してから報告を送ります。
2. ダッシュボードで送信データを開きます。`recent_errors` に `ReferenceError` が入っていて、`https://` のホストは含まれていません。
3. サーバー版でも、`user_agent` には `node` ではなく自分のブラウザーが表示されます。
4. `page_url` にはパスがあり、クエリ文字列はありません。
5. `steps` を 10 文字にして報告を送ります。Ikiform は `steps` を示して `422` を返し、フォームがメッセージを表示します。

## ハマりどころ

- ファイルアップロードにはまだ対応していません。ファイル付きの送信には `422 files_not_supported` が返ります。スクリーンショットへのリンクを尋ねるか、先に画像を自分のストレージにアップロードしてその URL を送信してください。
- 各値は最大 16 KB です。400 文字に切り詰めたエラー 5 件なら十分に収まりますが、コンソールの全出力は収まりません。
- Chromium 系のブラウザーはユーザーエージェント文字列の一部を固定しています。たとえば macOS のバージョンは常に `10_15_7` です。トリアージには OS のバージョンではなく、ブラウザー名とメジャーバージョンを使ってください。
- 別の origin のスクリプトからのエラーは、そのスクリプトが CORS ヘッダー付きで配信され `crossorigin` で読み込まれていない限り、`Script error.` としか表示されません。
- フォームの内容、ローカルストレージ、Cookie を報告に含めないでください。パスワードやトークンが入っていることがあり、それを平文で保存することになります。

## トラブルシューティング

| 問題 | 対処法 |
| --- | --- |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |
| `422 files_not_supported` | フォームの `<input type="file">` でファイルが選択されています。ファイルアップロードにはまだ対応していません。入力欄を削除するか、代わりにリンクを尋ねてください。 |
| スクリーンショットのリンク付きの報告がスパムに入る | すべてのフィールドにあるリンクを数えてください。3 つ以上でスコアが加算され、同じ報告の繰り返しが重なるとしきい値に達します。エラーからホストを除去し、その報告をスパムではないとマークしてください。 |
| `403 origin_not_allowed` が返る、またはすべての送信がスパムになる | ページのドメインがフォームの **Allowed domains** にありません。ドメインと、ローカルテスト用の `localhost` を追加してください。登録したドメインのサブドメインは許可されます。 |

## よくある質問

### バグ報告フォームでブラウザーと OS を記録するには？

submit ハンドラーで navigator.userAgent を読み、フィールドとして送ります。フォームがブラウザーから直接送信する場合、Ikiform は同じ値を meta.user_agent として記録します。サーバーから送信する場合は、訪問者の user-agent ヘッダーを転送してください。

### バグ報告にコンソールのエラーを含められますか？

はい。error と unhandledrejection イベントを監視し、直近のメッセージを数件メモリに保持して、送信時に hidden フィールドに追加します。リンクがスパムスコアに数えられないよう、先にホスト名を除去してください。

### 報告者はスクリーンショットを添付できますか？

ファイルとしてはできません。Ikiform はファイル付きの送信を 422 files_not_supported で拒否します。代わりにスクリーンショットや録画へのリンクを尋ねてください。

### バグ報告から GitHub や Linear の課題を自動で作成できますか？

はい。GitHub や Linear の API を呼び出す小さなリレーへの Webhook で実現できます。Ikiform は各配信に署名し、失敗したら同じ webhook-id でリトライします。

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [React フィードバックウィジェット](https://ikiform.com/ja/guides/feedback-form/react-feedback-widget.md): React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
- [ベータテスターのフィードバック](https://ikiform.com/ja/guides/feedback-form/beta-tester-feedback.md): テスターとビルドごとの個別リンク、2 つの評価スケール、各ビルドが良くなったかがわかる週次サマリーを備えたベータ版フィードバックフォームです。
- [NPS アンケート](https://ikiform.com/ja/guides/feedback-form/nps-survey.md): メールに 0〜10 のスコアを載せ、スコアに応じて変わる追加質問を自分のページで尋ね、スクリプトで NPS を計算する NPS アンケートを実施します。
- [解約アンケート](https://ikiform.com/ja/guides/feedback-form/cancellation-survey.md): SaaS の解約フローで解約理由を尋ね、スキップはワンクリックのまま、理由を Stripe と Ikiform に保存し、解約を決して遅らせない方法。
