ウェブサイトやアプリのフィードバックフォーム2 分で読めます
ページ、ブラウザー、手順を記録するバグ報告フォームを追加する
手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
報告者には本人が知っていることだけを尋ねます。何がおかしかったか、その直前に何をしたか、どれほど作業が止まるかです。残りは送信時にページが埋めます。ページの URL、ブラウザーのユーザーエージェント、ビューポート、直近の JavaScript エラーです。フォームバックエンドに送信し、各報告を Webhook で課題管理ツールに転送します。
バグ報告の違い
バグ報告は再現できて初めて役に立ちますが、報告者が必要な詳細を知っていることはまれです。どのブラウザーかと尋ねれば「Chrome」や「いつものやつ」と返ってきます。URL を尋ねればページの名前が返ってきます。そこでフォームを 2 つに分けます。報告者にしか答えられない質問と、ページがすでに知っている情報です。
- 報告者が 1 行の要約を書きます。
- 壊れる直前に何をしたかを書きます。
- どれほど作業が止まるかを選びます。
- スクリーンショットや録画へのリンクを貼れます。
- 送信時に、ページがクエリ文字列を除いた自分のアドレスを追加します。
- ブラウザーのユーザーエージェントを追加します。
- ウィンドウサイズとピクセル比を追加します。
- 直近に発生した JavaScript エラーを、ホスト名を除いて追加します。
コードを形づくる制約が 3 つあります。
- リファラーはページではありません。 ブラウザーはクロスサイトの送信ではデフォルトで origin しか送らないので、
meta.referrerからパスはわかりません。page_urlを自分で送ってください。 - サーバーからの送信ではブラウザーが隠れます。 Ikiform はリクエストを送った側のユーザーエージェントを記録します。サーバーアクションからなら、それは自分のサーバーです。訪問者の
user-agentヘッダーをフィールドとして転送してください。 - スタックトレースはリンクだらけです。 各フレームにスクリプトの URL があり、リンクが 3 つ以上あるとスパムスコアに 2 が加わります。エラーを送る前にホストを除去しないと、スクリーンショットのリンクを貼った報告がスパムに傾くことがあります。
フィールド
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
深刻度は優先度のラベルではなく、報告者にどう影響するかで表現します。「先に進めない」なら人は正確に選びますが、「Critical」だと何でもそれを選びます。手順は最低 20 文字なので、「壊れてる」が対応できる文章に変わります。
状況を記録する
ブラウザーは、本人が入力し終えたあとの submit ハンドラーで hidden フィールドを埋めます。そのため、報告を書いている間に起きたエラーもエラー一覧に含まれます。
フォームは普通の方法で送信し、スクリプトは submit の間、ブラウザーがリクエストを組み立てる前に hidden 入力欄を埋めるだけです。ビルド時に <html> 要素に data-app-version を設定してください。ブラウザーが直接送信するので、Ikiform 自身の meta.user_agent は 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>小さなモジュールが直近 5 件のエラーをメモリに保持し、ブラウザーの情報を返します。フォームは action をラップして送信時に FormData へ情報を追加し、サーバーアクションが 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>
);
}同じ error-buffer.ts モジュールを src/lib に置きます。use:enhance は送信前の FormData を渡してくれるので、情報はそこに入れます。action はリクエストからユーザーエージェントを、公開環境変数からバージョンを読みます。両方を src/env.ts で defineEnvVars を使って宣言し、PUBLIC_APP_VERSION には public: true を付けると、$app/env/private と $app/env/public から読めます。
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 はサーバー専用の環境変数に置いてください。成功メッセージには送信 ID を表示します。報告者があとでサポートに連絡してきたとき、その ID で報告が 1 回の検索で見つかります。バグ報告テンプレートには、状況の記録を省いた短い版があります。
課題管理ツールへ
報告は修正を追跡する場所に置くべきです。課題を作成するリレーを指す Webhook を追加します。summary をタイトルに、手順と状況を本文に、severity をラベルにします。署名チェックとリトライを含むそのリレーは Linear と GitHub Issues のガイドにあります。ペイロードに含まれるすべての meta の値は Webhook のドキュメントに一覧があります。手順が読みやすいよう、記録したフィールドは課題本文の最後の折りたたみブロックに入れてください。
| フィールド | 課題では |
|---|---|
summary | タイトル |
steps, expected | 本文の先頭 |
severity | ラベル。blocker で絞り込める |
page_url, user_agent, viewport, app_version | 手順の下の details ブロック |
recent_errors | 最後にコードブロック |
動作を確認する
- デスクトップのブラウザーでページを開き、コンソールで
undefinedFunction()を実行してから報告を送ります。 - ダッシュボードで送信データを開きます。
recent_errorsにReferenceErrorが入っていて、https://のホストは含まれていません。 - サーバー版でも、
user_agentにはnodeではなく自分のブラウザーが表示されます。 page_urlにはパスがあり、クエリ文字列はありません。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 を追加してください。登録したドメインのサブドメインは許可されます。 |
よくある質問
submit ハンドラーで navigator.userAgent を読み、フィールドとして送ります。フォームがブラウザーから直接送信する場合、Ikiform は同じ値を meta.user_agent として記録します。サーバーから送信する場合は、訪問者の user-agent ヘッダーを転送してください。
はい。error と unhandledrejection イベントを監視し、直近のメッセージを数件メモリに保持して、送信時に hidden フィールドに追加します。リンクがスパムスコアに数えられないよう、先にホスト名を除去してください。
ファイルとしてはできません。Ikiform はファイル付きの送信を 422 files_not_supported で拒否します。代わりにスクリーンショットや録画へのリンクを尋ねてください。
はい。GitHub や Linear の API を呼び出す小さなリレーへの Webhook で実現できます。Ikiform は各配信に署名し、失敗したら同じ webhook-id でリトライします。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com