웹사이트나 앱용 피드백 폼4분 분량
페이지, 브라우저, 재현 단계를 담는 버그 리포트 폼 추가하기
재현 단계와 심각도는 사람에게 묻고, URL, 브라우저, 뷰포트, 최근 오류는 페이지가 알아서 추가하는 버그 리포트 폼입니다.
리포터에게는 그 사람이 아는 것만 물으세요. 무엇이 잘못됐는지, 직전에 무엇을 했는지, 얼마나 막히는지입니다. 나머지는 제출 시점에 페이지가 채우게 하세요. 페이지 URL, 브라우저의 user agent, 뷰포트, 최근 JavaScript 오류 몇 개입니다. 폼 백엔드로 POST하고 각 리포트를 웹훅으로 이슈 트래커에 전달하세요.
버그 리포트가 다른 점
버그 리포트는 재현할 수 있어야만 쓸모가 있는데, 리포터는 필요한 세부 정보를 거의 모릅니다. 어떤 브라우저를 쓰는지 물으면 "Chrome"이나 "그냥 기본 거"라고 답합니다. URL을 물으면 페이지 이름이 돌아옵니다. 그러니 폼을 둘로 나누세요. 리포터만 답할 수 있는 질문과, 페이지가 이미 알고 있는 맥락입니다.
- 리포터가 한 줄 요약을 씁니다.
- 문제가 생기기 직전에 무엇을 했는지 설명합니다.
- 얼마나 심하게 막히는지 고릅니다.
- 스크린샷이나 녹화 링크를 붙여 넣을 수 있습니다.
- 제출 시 페이지가 쿼리 문자열을 뺀 자기 주소를 추가합니다.
- 브라우저의 user agent를 추가합니다.
- 창 크기와 픽셀 비율을 추가합니다.
- 페이지가 본 최근 JavaScript 오류 몇 개를 호스트 이름을 지우고 추가합니다.
코드에 영향을 주는 제약은 세 가지입니다.
- referrer는 페이지가 아닙니다. 브라우저는 기본적으로 크로스 사이트 POST에 origin만 보내므로
meta.referrer로는 경로를 알 수 없습니다.page_url을 직접 보내세요. - 서버 POST는 브라우저를 가립니다. Ikiform은 요청을 보낸 쪽의 user agent를 기록합니다. 서버 액션에서 보내면 그건 내 서버입니다. 방문자의
user-agent헤더를 필드로 전달하세요. - 스택 트레이스는 링크투성이입니다. 프레임마다 스크립트 URL이 있고, 링크가 3개 이상이면 스팸 점수에 2가 더해집니다. 오류를 보내기 전에 호스트를 지우세요. 그러지 않으면 스크린샷 링크를 붙여 넣은 리포트가 스팸으로 넘어갈 수 있습니다.
필드
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
심각도는 우선순위 레이블이 아니라 리포터에게 미치는 영향으로 표현합니다. 사람들은 "더 진행할 수 없음"은 정확하게 고르지만 "Critical"은 무엇에든 고릅니다. 재현 단계는 최소 20자라서 "고장 났어요"가 대응할 수 있는 문장으로 바뀝니다.
맥락 수집하기
브라우저는 사람이 입력을 마친 뒤 submit 핸들러에서 hidden 필드를 채우므로, 리포트를 쓰는 동안 발생한 오류도 목록에 포함됩니다.
폼은 일반적인 방식으로 POST하고, 스크립트는 submit 중 브라우저가 요청을 만들기 전에 hidden 입력만 채웁니다. 빌드 시 <html> 요소에 data-app-version을 설정하세요. 브라우저가 직접 POST하므로 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>작은 모듈이 최근 오류 다섯 개를 메모리에 두고 브라우저 맥락을 반환합니다. 폼은 액션을 감싸 제출 시 FormData에 맥락을 추가하고, 서버 액션은 headers()에서 방문자의 user agent를 추가합니다.
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를 넘겨주므로 맥락을 거기에 넣습니다. 액션은 요청에서 user agent를, 공개 환경 변수에서 버전을 읽습니다. $app/env/private와 $app/env/public이 읽을 수 있도록 둘 다 src/env.ts에서 defineEnvVars로 선언하고, PUBLIC_APP_VERSION에는 public: true를 지정하세요.
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로 한 번에 리포트를 찾을 수 있습니다. 버그 리포트 템플릿에는 맥락 수집 없이 더 짧은 버전이 있습니다.
이슈 트래커로 보내기
리포트는 수정이 추적될 곳에 있어야 합니다. 이슈를 만드는 릴레이를 가리키는 웹훅을 추가하세요. summary는 제목으로, 재현 단계와 맥락은 본문으로, severity는 레이블로 씁니다. Linear와 GitHub Issues 가이드에 서명 확인과 재시도를 갖춘 릴레이가 있습니다. 웹훅 문서에 페이로드가 담는 모든 meta 값이 나와 있습니다. 재현 단계가 잘 읽히도록 수집한 필드는 이슈 본문 끝의 접힌 블록에 넣으세요.
| 필드 | 이슈에서 |
|---|---|
summary | 제목 |
steps, expected | 본문, 맨 앞 |
severity | 레이블. blocker를 필터링할 수 있습니다 |
page_url, user_agent, viewport, app_version | 재현 단계 아래의 details 블록 |
recent_errors | 코드 블록, 맨 끝 |
작동 확인하기
- 데스크톱 브라우저에서 페이지를 열고 콘솔에서
undefinedFunction()을 실행한 뒤 리포트를 보내세요. - 대시보드에서 제출을 여세요.
recent_errors에https://호스트 없이ReferenceError가 들어 있습니다. - 서버 버전에서도
user_agent에node가 아니라 내 브라우저가 표시됩니다. page_url에는 경로는 있지만 쿼리 문자열은 없습니다.- 10자짜리
steps로 리포트를 보내세요. Ikiform이steps를 지목하는422로 응답하고, 폼이 메시지를 표시합니다.
주의할 점
- 파일 업로드는 아직 지원하지 않습니다. 파일이 담긴 POST는
422 files_not_supported를 받습니다. 스크린샷 링크를 요청하거나, 이미지를 먼저 자체 스토리지에 올리고 그 URL을 제출하세요. - 각 값은 최대 16 KB입니다. 400자로 자른 오류 다섯 개는 그보다 훨씬 작지만, 콘솔 전체를 덤프하면 넘을 수 있습니다.
- Chromium 브라우저는 macOS 버전처럼 user agent 문자열의 일부를 고정합니다. macOS 버전은 항상
10_15_7로 나옵니다. 분류할 때는 OS 버전이 아니라 브라우저 이름과 메이저 버전을 쓰세요. - 다른 origin의 스크립트에서 난 오류는 그 스크립트가 CORS 헤더와 함께 제공되고
crossorigin으로 로드되지 않는 한Script error.만 표시됩니다. - 폼 내용, 로컬 스토리지, 쿠키를 리포트에 담지 마세요. 비밀번호와 토큰이 들어 있을 수 있고, 그것을 평문으로 저장하게 됩니다.
문제 해결
| 문제 | 해결 방법 |
|---|---|
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 나옵니다. 입력의 name을 필드 ID와 맞추세요. |
422 files_not_supported | 폼에 파일이 선택된 <input type="file">이 있습니다. 파일 업로드는 아직 지원하지 않습니다. 입력을 지우거나 대신 링크를 요청하세요. |
| 스크린샷 링크가 있는 리포트가 스팸으로 분류됨 | 모든 필드의 링크 수를 세어 보세요. 3개 이상이면 점수가 더해지고, 같은 리포트가 반복되면 임계값에 닿습니다. 오류에서 호스트를 지우고 리포트를 스팸 아님으로 표시하세요. |
403 origin_not_allowed, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
질문
submit 핸들러에서 navigator.userAgent를 읽어 필드로 보내세요. 폼이 브라우저에서 바로 POST하면 Ikiform이 같은 값을 meta.user_agent로 기록하고, 서버에서 POST하면 방문자의 user-agent 헤더를 전달해야 합니다.
네. error와 unhandledrejection 이벤트를 듣고, 최근 메시지 몇 개를 메모리에 두었다가 제출 시 hidden 필드에 추가하세요. 링크가 스팸 점수에 포함되지 않도록 먼저 호스트 이름을 지우세요.
파일로는 안 됩니다. Ikiform은 파일이 담긴 POST를 422 files_not_supported로 거부합니다. 대신 스크린샷이나 녹화 링크를 요청하세요.
네. GitHub나 Linear API를 호출하는 작은 릴레이에 웹훅을 연결하면 됩니다. Ikiform은 각 전달에 서명하고 실패하면 같은 webhook-id로 재시도합니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.